REACTJS STACK WITH RBAC TESTED

pull/2/head
ahmed.mujtaba 2026-08-05 17:41:46 +05:00
parent 92c06d9335
commit cfaeb62466
106 changed files with 11208 additions and 4841 deletions

3
.gitignore vendored
View File

@ -45,10 +45,9 @@ tmp/
temp/ temp/
.cache/ .cache/
# Frontend / auth build # Frontend build
node_modules/ node_modules/
frontend/dist/ frontend/dist/
/auth/
**.pdf **.pdf
**_**_**.py **_**_**.py

View File

@ -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()

View File

@ -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=

View File

@ -2,9 +2,23 @@
<html lang="en" data-theme="light"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<title>TalentFlow · Sign in</title> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<!-- Per-route titles are set at runtime by useRouteMeta. -->
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,28 @@
{ {
"name": "hr-ats-auth", "name": "hr-ats-portal",
"private": true, "private": true,
"version": "0.0.1", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:lan": "vite --host",
"build": "vite build", "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": { "dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.0" "react-router-dom": "^7.6.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react": "^4.5.0",
"esbuild": "^0.28.1",
"jsdom": "^30.0.1",
"vite": "^6.3.5" "vite": "^6.3.5"
} }
} }

131
frontend/smoke.test.mjs Normal file
View File

@ -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('<!doctype html><html><body><div id="root"></div></body></html>', {
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)

View File

@ -1,22 +1,91 @@
import { lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' 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 Login from './pages/Login'
import Signup from './pages/Signup' import Signup from './pages/Signup'
import ForgotPassword from './pages/ForgotPassword' import ForgotPassword from './pages/ForgotPassword'
import ConfirmEmail from './pages/ConfirmEmail' 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() { export default function App() {
return ( return (
<BrowserRouter basename={basename}> <BrowserRouter>
<AuthProvider>
<LegacyHashRedirect />
<Routes> <Routes>
<Route path="/" element={<Login />} /> {/* Public. These keep the /auth prefix as ROUTE paths so the backend's
<Route path="/login" element={<Login />} /> CONFIRM_EMAIL_PATH=/auth/confirm-email links resolve unchanged. */}
<Route path="/signup" element={<Signup />} /> <Route path="/auth" element={<Navigate to="/auth/login" replace />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/auth/login" element={<Login />} />
<Route path="/confirm-email" element={<ConfirmEmail />} /> <Route path="/auth/signup" element={<Signup />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="/auth/forgot-password" element={<ForgotPassword />} />
<Route path="/auth/confirm-email" element={<ConfirmEmail />} />
{/* Protected */}
<Route
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
{ROUTES.map((r) => {
const Screen = SCREENS[r.path]
return (
<Route
key={r.path}
path={`/${r.path}`}
element={
r.permission ? (
<RequireAuth permission={r.permission}>
<Screen />
</RequireAuth>
) : (
<Screen />
)
}
/>
)
})}
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@ -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() })
}
}

View File

@ -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'

View File

@ -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,
})
}

46
frontend/src/api/auth.js Normal file
View File

@ -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,
})
}

17
frontend/src/api/inbox.js Normal file
View File

@ -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 } })
}

31
frontend/src/api/roles.js Normal file
View File

@ -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')
}

34
frontend/src/api/users.js Normal file
View File

@ -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 } })
}

View File

@ -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 (
<div className={`ai-dock${open ? ' open' : ''}`} id="aiDock">
<div className="ai-dock-inner">
{open && (
<>
<div className="card-head" style={{ borderRadius: 0 }}>
<div>
<h3><Icon name="sparkles" /> AI Assistant</h3>
</div>
<div className="flex items-center gap-8">
<button
className="btn btn-ghost btn-sm"
onClick={() => {
onClose()
navigate('/aiassistant')
}}
>
Expand
</button>
<button className="modal-close" onClick={onClose} aria-label="Close">
<Icon name="x" />
</button>
</div>
</div>
<div style={{ flex: 1, padding: 16, overflow: 'hidden', display: 'flex' }}>
<Chat compact />
</div>
</>
)}
</div>
</div>
)
}

View File

@ -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 (
<div id="app">
<Sidebar
collapsed={collapsed}
mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed}
badges={badges}
/>
<div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
<main className="content" id="main-content" ref={contentRef}>
<Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}>
<Outlet />
</Suspense>
</main>
</div>
<button
className="ai-fab"
onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant"
aria-label="Open AI Assistant"
>
<Icon name="sparkles" />
</button>
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} />
<div
className={`scrim${navOpen ? ' open' : ''}`}
onClick={() => setNavOpen(false)}
aria-hidden="true"
/>
</div>
)
}

View File

@ -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 (
<div className="topbar-search" onClick={(e) => e.stopPropagation()}>
<Icon name="search" />
<input
type="text"
ref={inputRef}
value={q}
placeholder="Search jobs, candidates, managers…"
autoComplete="off"
onChange={(e) => {
setQ(e.target.value)
setOpen(Boolean(e.target.value.trim()))
}}
onFocus={() => setOpen(Boolean(q.trim()))}
/>
<div className={`search-results${open && results ? ' open' : ''}`} ref={boxRef}>
{results && (
<>
{results.jobs.length > 0 && <div className="search-group-label">Jobs</div>}
{results.jobs.map((j) => (
<div key={j.id} className="search-item" onClick={() => go('/jobs', { openJob: j.id })}>
<span className="kpi-icn i-indigo" style={{ width: 32, height: 32, borderRadius: 8 }}>
<Icon name="briefcase" />
</span>
<div>
<div className="si-title">{j.title}</div>
<div className="si-sub">{j.id} · {j.department}</div>
</div>
</div>
))}
{results.candidates.length > 0 && <div className="search-group-label">Candidates</div>}
{results.candidates.map((c) => (
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="si-title">{c.name}</div>
<div className="si-sub">{c.jobTitle}</div>
</div>
</div>
))}
{results.managers.length > 0 && <div className="search-group-label">Hiring Managers</div>}
{results.managers.map((m) => (
<div key={m.id} className="search-item" onClick={() => go('/managers', { openManager: m.id })}>
<Avatar name={m.name} initials={m.initials} color={m.color} />
<div>
<div className="si-title">{m.name}</div>
<div className="si-sub">{m.title}</div>
</div>
</div>
))}
{empty && <div className="search-empty">No results for &ldquo;{q}&rdquo;</div>}
</>
)}
</div>
<kbd className="search-kbd">K</kbd>
</div>
)
}

View File

@ -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
}

View File

@ -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 (
<aside
className={`sidebar${collapsed ? ' collapsed' : ''}${mobileOpen ? ' mobile-open' : ''}`}
id="sidebar"
>
<div className="sidebar-brand">
<div className="brand-logo">
<BrandMark />
</div>
<div className="brand-text">
<span className="brand-name">TalentFlow</span>
<span className="brand-sub">Utopia Brands · ATS</span>
</div>
<button
className="sidebar-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<Icon name="chevron-left" />
</button>
</div>
<nav className="sidebar-nav">
{NAV_GROUPS.map((group) => {
const items = visible.filter((r) => r.group === group)
if (!items.length) return null
return (
<div key={group}>
<div className="nav-section-label">{group}</div>
{items.map((r) => {
const count = r.badge ? badges?.[r.badge] : null
return (
<NavLink
key={r.path}
to={`/${r.path}`}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<Icon name={r.icon} />
<span>{r.title}</span>
{r.tag && <span className="nav-badge nav-badge-ai">{r.tag}</span>}
{count ? (
<span
className={`nav-badge${r.badge === 'inbox' || r.badge === 'notifications' ? ' nav-badge-alert' : ''}`}
>
{count}
</span>
) : null}
</NavLink>
)
})}
</div>
)
})}
</nav>
<div className="sidebar-footer">
<div className="usage-card">
<div className="usage-top">
<span>Seats used</span>
<span>14 / 20</span>
</div>
<div className="usage-bar">
<div className="usage-fill" style={{ width: '70%' }} />
</div>
</div>
</div>
</aside>
)
}

150
frontend/src/app/Topbar.jsx Normal file
View File

@ -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 (
<header className="topbar">
<button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" />
</button>
<GlobalSearch inputRef={searchRef} />
<div className="topbar-actions">
<button
className="icon-btn"
onClick={toggleTheme}
aria-pressed={theme === 'dark'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span className="icon-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span>
</button>
<DropdownGroup>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (
<button className="icon-btn" onClick={toggle} title="Messages" aria-label="Messages">
<Icon name="message" />
<span className="dot dot-blue" />
</button>
)}
>
<div className="dropdown-head">Messages</div>
<div className="dd-scroll">
{messages.map((m) => (
<div key={m.id ?? m.name} className={`notif-row${m.unread ? ' unread' : ''}`}>
<Avatar name={m.name} initials={m.initials} color={m.color} />
<div className="notif-body">
<div className="notif-title">{m.name}</div>
<div className="notif-text">{m.text}</div>
<div className="notif-time">{m.time} ago</div>
</div>
</div>
))}
</div>
<div className="dropdown-foot">
<Link to="/notifications">Open inbox</Link>
</div>
</Dropdown>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (
<button className="icon-btn" onClick={toggle} title="Notifications" aria-label="Notifications">
<Icon name="bell" />
<span className="dot dot-red" />
</button>
)}
>
<div className="dropdown-head">
Notifications
<button className="link-btn" onClick={markAllRead}>Mark all read</button>
</div>
<div className="dd-scroll">
{notifications.slice(0, 6).map((n) => (
<div key={n.id ?? n.title} className={`notif-row${n.unread ? ' unread' : ''}`}>
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
<div className="notif-body">
<div className="notif-title">{n.title}</div>
<div className="notif-text">{n.text}</div>
<div className="notif-time">{n.time}</div>
</div>
</div>
))}
</div>
<div className="dropdown-foot">
<Link to="/notifications">View all</Link>
</div>
</Dropdown>
<div className="topbar-divider" />
<Dropdown
trigger={({ toggle }) => (
<button className="profile-btn" onClick={toggle}>
<span className="avatar avatar-grad">{initialsFromName(name)}</span>
<span className="profile-meta">
<span className="profile-name">{name}</span>
<span className="profile-role">{role}</span>
</span>
<Icon name="chevron-down" className="chev" />
</button>
)}
>
<div className="dropdown-profile">
<span className="avatar avatar-grad avatar-lg">{initialsFromName(name)}</span>
<div>
<div className="dp-name">{name}</div>
<div className="dp-email">{email}</div>
</div>
</div>
<div className="dropdown-divider" />
<Link className="dropdown-link" to="/settings"><Icon name="user" />My Profile</Link>
<Link className="dropdown-link" to="/settings"><Icon name="settings" />Settings</Link>
<Link className="dropdown-link" to="/help"><Icon name="help" />Help Center</Link>
<div className="dropdown-divider" />
<button className="dropdown-link danger" onClick={signOut}>
<Icon name="logout" />Sign out
</button>
</Dropdown>
</DropdownGroup>
</div>
</header>
)
}

View File

@ -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 (
<div className="chat-wrap" style={compact ? { height: '100%' } : undefined}>
<div className="chat-scroll" ref={scrollRef}>
{!started ? (
<>
<div className="ai-hero">
<div className="ai-logo"><Icon name="sparkles" /></div>
<h2 style={{ fontSize: compact ? 18 : 22, marginBottom: 6 }}>AI Recruiter Assistant</h2>
<p className="text-muted">Ask anything about your candidates, jobs, and pipeline</p>
</div>
<div
style={{
display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center',
maxWidth: 720, margin: '0 auto 10px',
}}
>
{aiPrompts.slice(0, compact ? 6 : 12).map((p) => (
<button key={p.text} className="prompt-chip" onClick={() => send(p.prompt)}>
<Icon name={p.icon} /> {p.text}
</button>
))}
</div>
</>
) : (
messages.map((m) => (
<div className="chat-msg" key={m.id}>
<div className={`chat-av ${m.role === 'you' ? 'user' : 'ai'}`}>
<Icon name={m.role === 'you' ? 'users' : 'sparkles'} />
</div>
<div className="chat-bubble">
<div className="chat-role">{m.role === 'you' ? 'You' : 'AI Assistant'}</div>
{m.typing ? (
<div className="chat-typing"><span /><span /><span /></div>
) : (
<div className="chat-text">{m.text ?? m.node}</div>
)}
</div>
</div>
))
)}
</div>
<div style={{ paddingTop: 12 }}>
<div className="chat-input-bar">
<textarea
ref={inputRef}
rows={1}
value={value}
placeholder="Message AI Assistant…"
onChange={(e) => {
setValue(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 140)}px`
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<button className="chat-send" onClick={() => send()} aria-label="Send">
<Icon name="arrow-right" />
</button>
</div>
<p className="text-muted text-sm" style={{ textAlign: 'center', marginTop: 8 }}>
UI preview · responses are simulated. <Icon name="lock" /> API-ready for backend integration.
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,185 @@
/* ============================================================
replies.jsx the keyword-matched canned responses from js/aiassistant.js.
These were HTML strings assembled with innerHTML. They are JSX now, which is
the whole point: the prototype's chat was also the one place anyone had
thought about escaping, and they did it partially (`text.replace(/</g,'&lt;')`
on the input only not &, " or '). JSX escapes everything, everywhere.
Still a stub. Wire a real endpoint into Chat's `send` to make it live.
============================================================ */
export function reply(prompt, { candidates, recruiters }) {
const p = prompt.toLowerCase()
if (p.includes('rank')) {
const top = [...candidates].sort((a, b) => b.aiScore - a.aiScore).slice(0, 5)
return (
<>
<p>Here are the top-ranked candidates by ATS match score:</p>
<ul>
{top.map((c, i) => (
<li key={c.id}>
<b>{i + 1}. {c.name}</b> {c.aiScore}% match · {c.jobTitle} · {c.recommendation}
</li>
))}
</ul>
<p className="text-muted">
This is a UI preview. Connect an LLM endpoint to generate live rankings from
resume + JD embeddings.
</p>
</>
)
}
if (p.includes('compare')) {
const [a, b] = candidates.slice(0, 2)
return (
<>
<p>Comparing <b>{a.name}</b> vs <b>{b.name}</b>:</p>
<ul>
<li><b>Experience:</b> {a.experience}y vs {b.experience}y</li>
<li><b>ATS Score:</b> {a.aiScore}% vs {b.aiScore}%</li>
<li><b>Recommendation:</b> {a.recommendation} vs {b.recommendation}</li>
</ul>
<p><b>Suggested:</b> {a.aiScore >= b.aiScore ? a.name : b.name} appears stronger on core criteria.</p>
</>
)
}
if (p.includes('job description') || p.includes('jd')) {
return (
<>
<p><b>Senior Product Designer</b></p>
<p>
Were looking for a Senior Product Designer to craft intuitive, delightful
experiences across our platform. Youll own end-to-end design, from research to
polished UI, and partner closely with product and engineering.
</p>
<p><b>Responsibilities:</b> lead design for key initiatives, run user research, build and maintain design systems, mentor peers.</p>
<p><b>Requirements:</b> 5+ years product design, strong portfolio, fluency in Figma, systems thinking.</p>
</>
)
}
if (p.includes('interview question')) {
return (
<>
<p>Here are role-specific interview questions:</p>
<ul>
<li>Walk me through how youd design a system to handle 1M concurrent users.</li>
<li>Describe a technically challenging project and the tradeoffs you made.</li>
<li>How do you approach debugging a production incident under time pressure?</li>
<li>Tell me about a time you disagreed with a teammate on an approach.</li>
</ul>
</>
)
}
if (p.includes('summar')) {
const c = candidates[0]
return (
<>
<p><b>Resume summary {c.name}</b></p>
<p>
{c.experience} years of experience, currently {c.currentTitle} at {c.currentCompany}.
Strong in {c.skills.slice(0, 3).join(', ')}. ATS match {c.aiScore}% for {c.jobTitle}. {c.recommendation}.
</p>
</>
)
}
if (p.includes('email')) {
return (
<>
<p><b>Subject:</b> Interview Invitation Next Steps</p>
<p>Hi [Candidate],</p>
<p>
Thank you for applying. We were impressed by your background and would love to
invite you to an interview. Please share your availability for this week.
</p>
<p>Best regards,<br />Talent Team</p>
</>
)
}
if (p.includes('offer letter')) {
return (
<>
<p><b>Offer Letter</b></p>
<p>
Dear [Candidate], We are pleased to offer you the position of Product Manager at a
base salary of $160,000, plus equity and benefits. This offer is contingent on
standard background checks.
</p>
<p>Were excited about the possibility of you joining the team.</p>
</>
)
}
if (p.includes('skill gap')) {
return (
<>
<p><b>Skill Gap Analysis Engineering pipeline</b></p>
<ul>
<li><span className="skill-pill skill-missing">Kubernetes</span> under-represented (only 22% of pipeline)</li>
<li><span className="skill-pill skill-missing">System Design</span> gap at senior level</li>
<li><span className="skill-pill skill-matched">React</span> well covered</li>
</ul>
<p>Consider sourcing candidates with cloud-native infra experience.</p>
</>
)
}
if (p.includes('pipeline')) {
return (
<>
<p><b>Pipeline health analysis</b></p>
<ul>
<li>{candidates.length} active candidates across 6 stages</li>
<li>Conversion Applied Interview: ~28%</li>
<li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li>
<li>Offer acceptance trending at 82%</li>
</ul>
<p>Recommendation: accelerate assessment turnaround to improve velocity.</p>
</>
)
}
if (p.includes('productivity') || p.includes('recruiter')) {
const top = [...recruiters].sort((a, b) => b.hires - a.hires)[0]
return (
<>
<p><b>Team productivity this month</b></p>
<ul>
<li>Top performer: {top?.name}</li>
<li>Avg time-to-hire: 27 days (3 days faster than last month)</li>
<li>Interview completion rate: 91%</li>
</ul>
</>
)
}
if (p.includes('recommend') || p.includes('suggest')) {
const c = [...candidates].sort((a, b) => b.aiScore - a.aiScore)[0]
return (
<p>
<b>Top recommendation:</b> {c.name} ({c.aiScore}% match) for {c.jobTitle}. Strong on{' '}
{c.matchedSkills.slice(0, 2).join(' & ')}. Id prioritise scheduling a screen this week.
</p>
)
}
return (
<>
<p>
I can help with ranking candidates, comparing profiles, drafting JDs, interview
questions, emails, offer letters, skill-gap and pipeline analysis, and more.
</p>
<p className="text-muted">
This is a fully-designed interface. Wire an AI endpoint (Claude / OpenAI) into the
chats <code>send</code> handler to make responses live.
</p>
</>
)
}

View File

@ -0,0 +1,54 @@
/* ============================================================
routes.js the 23-route information architecture.
This is the one artefact ADR 0013 says to preserve outright: the module
breakdown, nav grouping and screen inventory are a validated UX artefact
independent of the fake data behind them. Titles are verbatim from
js/app.js:7-16 and the group order is verbatim from index.html's sidebar.
`permission` gates the nav item and the route guard. See auth/permissions.js:
this is cosmetic only /users/*, /roles/* and /permissions/* enforce
server-side. Routes with no matching backend module carry null and are open
to any signed-in user.
============================================================ */
export const NAV_GROUPS = ['Workspace', 'Recruiting', 'Hiring', 'Insights', 'System']
export const ROUTES = [
// --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },
{ path: 'pipeline', title: 'Pipeline', icon: 'pipeline', group: 'Workspace', permission: 'pipeline.view' },
// --- Recruiting ---
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
{ path: 'jobboard', title: 'Job Board', icon: 'layers', group: 'Recruiting', permission: 'job_board.view' },
{ path: 'recruiterhub', title: 'Recruiter Hub', icon: 'check-circle', group: 'Recruiting', permission: 'analytics.view' },
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: null, badge: 'tasks' },
{ path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' },
// --- Hiring ---
{ path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: null },
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
// --- Insights ---
{ path: 'reports', title: 'Reports', icon: 'reports', group: 'Insights', permission: 'reports.view' },
{ path: 'analytics', title: 'Analytics', icon: 'analytics', group: 'Insights', permission: 'analytics.view' },
{ path: 'aistudio', title: 'AI Studio', icon: 'zap', group: 'Insights', permission: null },
{ path: 'notifications', title: 'Notifications', icon: 'bell', group: 'Insights', permission: null, badge: 'notifications' },
// --- System ---
{ path: 'rbac', title: 'Access Control', icon: 'shield', group: 'System', permission: 'rbac_users.view' },
{ path: 'settings', title: 'Settings', icon: 'settings', group: 'System', permission: 'settings.view' },
{ path: 'help', title: 'Help', icon: 'help', group: 'System', permission: null },
]
export const ROUTE_BY_PATH = Object.fromEntries(ROUTES.map((r) => [r.path, r]))
export const DEFAULT_ROUTE = 'dashboard'

View File

@ -0,0 +1,95 @@
/* Shell hooks — the behaviours that lived loose in js/app.js's init(). */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { seedQuery } from '../data/seedQueries'
const SIDEBAR_KEY = 'tf-sidebar'
/** document.title and <html data-view> — CSS keys off data-view (js/app.js:38). */
export function useRouteMeta(route) {
useEffect(() => {
if (!route) return undefined
document.documentElement.setAttribute('data-view', route.path)
document.title = `TalentFlow · ${route.title}`
return () => document.documentElement.removeAttribute('data-view')
}, [route])
}
/**
* Mobile nav drawer. All four class toggles are load-bearing for the frozen CSS:
* `nav-open` on <html> is what the rules key off, because the AI FAB sits before
* .scrim in the DOM so no sibling selector can reach it.
*/
export function useNavOpen() {
const [navOpen, setNavOpen] = useState(false)
useEffect(() => {
document.documentElement.classList.toggle('nav-open', navOpen)
document.body.style.overflow = navOpen ? 'hidden' : ''
return () => {
document.documentElement.classList.remove('nav-open')
}
}, [navOpen])
return [navOpen, setNavOpen]
}
export function useSidebarCollapsed() {
// The prototype didn't persist this; a collapsed sidebar springing back open
// on every navigation reads as a bug.
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(SIDEBAR_KEY) === '1'
} catch {
return false
}
})
const toggle = useCallback(() => {
setCollapsed((c) => {
try {
localStorage.setItem(SIDEBAR_KEY, c ? '0' : '1')
} catch {
/* ignore */
}
return !c
})
}, [])
return [collapsed, toggle]
}
/** Cmd/Ctrl+K focuses search; Escape closes the drawer and the dock (js/app.js:297-300). */
export function useHotkeys({ onEscape }) {
const searchRef = useRef(null)
useEffect(() => {
const onKeyDown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
searchRef.current?.focus()
}
if (e.key === 'Escape') onEscape?.()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onEscape])
return searchRef
}
/**
* The four sidebar badge counts. App.updateBadges() was an imperative DOM write
* that every mutating call site had to remember to call; these are derived, so
* completing a task updates the badge with no call site involved at all.
*/
export function useBadges() {
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
return {
jobs: jobs.filter((j) => j.status === 'Open').length,
notifications: notifications.filter((n) => n.unread).length,
tasks: tasks.filter((t) => !t.done).length,
inbox: inbox.filter((i) => i.unread).length,
}
}

View File

@ -1,30 +0,0 @@
const STORAGE_KEY = 'tf-auth'
export function getSession() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
export function setSession(session) {
const payload = {
access_token: session.access_token,
refresh_token: session.refresh_token,
expires_in: session.expires_in,
data: session.data,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
return payload
}
export function clearSession() {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}

View File

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react'
export const AuthContext = createContext(null)
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}
/** `{ can, permissions }` — see auth/permissions.js on what this actually enforces. */
export function usePermission() {
const { can, permissions } = useAuth()
return { can, permissions }
}

View File

@ -0,0 +1,94 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AuthContext } from './AuthContext'
import { makeCan } from './permissions'
import { qk } from '../lib/queryKeys'
import { setSessionExpiredHandler } from '../lib/apiClient'
import { subscribe, getSession, setSession, mergeUser, clearSession } from '../lib/tokenStore'
import * as authApi from '../api/auth'
import * as usersApi from '../api/users'
const emptySnapshot = () => null
export default function AuthProvider({ children }) {
const session = useSyncExternalStore(subscribe, getSession, emptySnapshot)
const navigate = useNavigate()
const qc = useQueryClient()
// apiClient calls this when a refresh fails outright. Registered here so the
// HTTP layer never has to know about the router.
useEffect(() => {
setSessionExpiredHandler(() => {
clearSession()
qc.clear()
navigate('/auth/login?expired=1', { replace: true })
})
return () => setSessionExpiredHandler(() => {})
}, [navigate, qc])
// Permission bootstrap. This is a query rather than a useEffect fetch on
// purpose: TanStack Query dedupes, so React 19 StrictMode's double mount
// issues ONE request instead of two.
const me = useQuery({
queryKey: qk.auth.me(),
queryFn: () => usersApi.me().then((r) => r.data),
enabled: Boolean(session?.access_token),
staleTime: 5 * 60_000,
retry: false,
})
// Fold `permissions` into the stored session so a page reload has them before
// /users/me resolves otherwise the nav flickers on every refresh.
useEffect(() => {
if (me.data) mergeUser(me.data)
}, [me.data])
const signIn = useCallback(
async (email, password) => {
const res = await authApi.login(email, password)
setSession(res)
// Refetch /users/me for the new user; the old one's permissions must not leak.
await qc.invalidateQueries({ queryKey: qk.auth.me() })
return res
},
[qc],
)
const signOut = useCallback(() => {
// Client-side only: the backend has no logout endpoint, no denylist and no
// jti tracking, so the refresh token stays valid until its 7-day exp.
clearSession()
qc.clear()
navigate('/auth/login', { replace: true })
}, [navigate, qc])
const user = me.data ?? session?.data ?? null
const permissions = me.data?.permissions ?? session?.data?.permissions ?? null
const status = !session?.access_token
? 'anonymous'
: me.isError
? 'error'
: me.data || permissions
? 'authenticated'
: 'loading'
const value = useMemo(
() => ({
session,
user,
permissions,
status,
can: makeCan(permissions),
isAuthenticated: status === 'authenticated',
signIn,
signOut,
setSession,
}),
[session, user, permissions, status, signIn, signOut],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@ -0,0 +1,40 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
import Spinner from '../components/Spinner'
/**
* Route guard.
*
* Blocking on `loading` is deliberate: rendering the shell before /users/me
* resolves would paint the full 23-item nav and then remove items a moment
* later, which reads as a bug rather than as security.
*/
export default function RequireAuth({ children, permission }) {
const { status, can } = useAuth()
const location = useLocation()
if (status === 'anonymous') {
return <Navigate to="/auth/login" replace state={{ from: location }} />
}
if (status === 'error') {
return <Navigate to="/auth/login?expired=1" replace />
}
if (status === 'loading') {
return (
<div className="route-loading">
<Spinner label="Loading your workspace" />
</div>
)
}
if (permission && !can(permission)) return <Forbidden />
return children
}
export function Forbidden() {
return (
<div className="empty-state">
<h3>You dont have access to this page</h3>
<p>Ask an administrator to grant your role the required permission.</p>
</div>
)
}

View File

@ -0,0 +1,35 @@
/* ============================================================
permissions.js the 104-tag vocabulary, mirrored from the backend.
IMPORTANT WHAT THIS DOES AND DOES NOT DO
------------------------------------------
Everything here is COSMETIC: it hides nav items and blocks routes in the UI.
Real enforcement is `require_permission` on the server, and today that only
guards /users/*, /roles/* and /permissions/*. The other 20 screens are seed
data with no server behind them, and /inbox/fetch has no auth dependency at
all. A ticked box in the RBAC matrix is not an access control.
Source of truth: backend/users/permissions.py PermissionTag, which asserts at
startup that the enum equals the full modules x actions cross-product.
============================================================ */
export const MODULES = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users',
]
export const ACTIONS = [
'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure',
]
/** All 104 `module.action` tags. */
export const ALL_TAGS = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
/**
* Build a permission predicate. A null/undefined tag is always allowed routes
* like Tasks and Help have no backend module and are open to any signed-in user.
*/
export function makeCan(permissions) {
const set = new Set(permissions ?? [])
return (tag) => !tag || set.has(tag)
}

View File

@ -1,19 +1,8 @@
import { useEffect, useState } from 'react' import { useTheme } from '../theme/ThemeProvider'
import { toggleTheme } from '../theme'
export default function ThemeToggle() { export default function ThemeToggle() {
const [theme, setTheme] = useState( const { theme, toggleTheme } = useTheme()
() => document.documentElement.getAttribute('data-theme') || 'light', const onClick = toggleTheme
)
useEffect(() => {
setTheme(document.documentElement.getAttribute('data-theme') || 'light')
}, [])
function onClick() {
setTheme(toggleTheme())
}
const dark = theme === 'dark' const dark = theme === 'dark'
return ( return (
<button <button

View File

@ -1,11 +1,20 @@
/* ============================================================ /* ============================================================
data.js Realistic dummy dataset + generators seed.js Realistic dummy dataset + generators
Exposes global `DB` Ported from the prototype's js/data.js. The IIFE became an ES module and
============================================================ */ `window.DB` became the default export; the LCG and every generator are
(function () { unchanged, so the dataset is byte-identical to the prototype's.
'use strict';
// ---------- seeded pseudo-random for stable data ---------- Read this as a *display-requirements* artefact, not a data model see
docs/architecture/01-repository-assessment.md §2.2. Screens whose backend
endpoints exist read the API instead; the rest resolve from here.
============================================================ */
// The prototype pinned "today" to 2026-07-09 in ~8 places across five files so
// the generated relative dates stayed stable. Exported from one place now, so
// switching the app to real time is a one-line change.
export const TODAY = new Date('2026-07-09T09:00:00');
// ---------- seeded pseudo-random for stable data ----------
let seed = 88123; let seed = 88123;
function rand() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; } function rand() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
function pick(arr) { return arr[Math.floor(rand() * arr.length)]; } function pick(arr) { return arr[Math.floor(rand() * arr.length)]; }
@ -50,7 +59,7 @@
function initials(name) { return name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase(); } function initials(name) { return name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase(); }
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); } function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); } function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
function daysAgo(n) { const d = new Date('2026-07-09T09:00:00'); d.setDate(d.getDate() - n); return d; } function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
@ -182,19 +191,24 @@
} }
// ---------- Activity feed ---------- // ---------- Activity feed ----------
// The prototype stored each row as an HTML string with the candidate name
// interpolated into <b> tags, then injected it with innerHTML. That is one of
// the 38 XSS sinks, and it cannot be rendered without dangerouslySetInnerHTML.
// Rows are structured now: a `parts` array of plain strings and {b} spans that
// the view renders as JSX. Same words on screen, no markup in the data.
const activityTemplates = [ const activityTemplates = [
{ icon: 'user-plus', color: 'i-green', text: (n, j) => `<b>${n}</b> applied for <b>${j}</b>` }, { icon: 'user-plus', color: 'i-green', parts: (n, j) => [{ b: n }, ' applied for ', { b: j }] },
{ icon: 'calendar', color: 'i-blue', text: (n, j) => `Interview scheduled with <b>${n}</b> for <b>${j}</b>` }, { icon: 'calendar', color: 'i-blue', parts: (n, j) => ['Interview scheduled with ', { b: n }, ' for ', { b: j }] },
{ icon: 'check', color: 'i-teal', text: (n, j) => `<b>${n}</b> moved to <b>Offer</b> stage` }, { icon: 'check', color: 'i-teal', parts: (n) => [{ b: n }, ' moved to ', { b: 'Offer' }, ' stage'] },
{ icon: 'file', color: 'i-purple', text: (n, j) => `Offer sent to <b>${n}</b> for <b>${j}</b>` }, { icon: 'file', color: 'i-purple', parts: (n, j) => ['Offer sent to ', { b: n }, ' for ', { b: j }] },
{ icon: 'star', color: 'i-amber', text: (n, j) => `<b>${n}</b> completed an assessment` }, { icon: 'star', color: 'i-amber', parts: (n) => [{ b: n }, ' completed an assessment'] },
{ icon: 'x', color: 'i-red', text: (n, j) => `<b>${n}</b> was rejected for <b>${j}</b>` } { icon: 'x', color: 'i-red', parts: (n, j) => [{ b: n }, ' was rejected for ', { b: j }] }
]; ];
const activity = []; const activity = [];
for (let i = 0; i < 18; i++) { for (let i = 0; i < 18; i++) {
const t = pick(activityTemplates); const t = pick(activityTemplates);
const cand = pick(candidates); const cand = pick(candidates);
activity.push({ icon: t.icon, color: t.color, html: t.text(cand.name, cand.jobTitle), time: int(1, 300), candidateId: cand.id }); activity.push({ icon: t.icon, color: t.color, parts: t.parts(cand.name, cand.jobTitle), time: int(1, 300), candidateId: cand.id });
} }
activity.sort((a, b) => a.time - b.time); activity.sort((a, b) => a.time - b.time);
function relTime(mins) { function relTime(mins) {
@ -234,7 +248,7 @@
}; };
// ---------- KPIs ---------- // ---------- KPIs ----------
const today = new Date('2026-07-09'); const today = new Date(TODAY);
const kpis = { const kpis = {
openJobs: jobs.filter(j => j.status === 'Open').length, openJobs: jobs.filter(j => j.status === 'Open').length,
closedJobs: jobs.filter(j => j.status === 'Closed').length, closedJobs: jobs.filter(j => j.status === 'Closed').length,
@ -488,25 +502,39 @@
const recentlyViewed = []; const recentlyViewed = [];
const favorites = { candidates: [], jobs: [] }; const favorites = { candidates: [], jobs: [] };
// ---------- Expose ---------- // ---------- Formatters ----------
window.DB = { // Money keeps the prototype's hardcoded '$'. Postings span six jurisdictions and
departments, businessUnits, locations, empTypes, grades, jobStatuses, educationLevels, stages, sources, skillsPool, benefitsPool, // there is no currency field in this dataset — that is a data-model fix for the
// backend (01-repository-assessment.md §2.2 "Money"), not a migration change.
const money = n => '$' + n.toLocaleString('en-US');
const moneyK = n => '$' + Math.round(n / 1000) + 'k';
// ---------- Lookups ----------
// The prototype read these off the DB global. They now take their list as an
// argument so a screen can pass either the seed array or a cached API list.
const byId = (list, id) => list.find(x => x.id === id);
const getJob = id => byId(jobs, id);
const getCandidate = id => byId(candidates, id);
const getManager = id => byId(managers, id);
const getRecruiter = id => byId(recruiters, id);
const getRecruiterByName = n => recruiters.find(r => r.name === n);
// ---------- Exports ----------
// Named exports so screens import only what they use, and `avatarColor`/`initials`
// can reach the UI primitives without dragging the whole dataset in (the prototype's
// UI.avatar() read them off the DB global, coupling every primitive to seed data).
export {
// reference lists — never mutated, imported directly rather than through the cache
departments, businessUnits, locations, empTypes, grades, jobStatuses, educationLevels,
stages, sources, skillsPool, benefitsPool, interviewTypes, meetingTypes, companies,
sourceMeta, inboxSources, processingStatuses, resumeStatuses,
rbacModules, permTypes, savedSearches, evalTemplates, aiModules, aiPrompts,
// mutable buckets — these go through the query cache (see data/seedQueries.js)
recruiters, managers, jobs, candidates, interviews, assessments, offers, recruiters, managers, jobs, candidates, interviews, assessments, offers,
activity, notifications, messages, analytics, kpis, roles, users, activity, notifications, messages, analytics, kpis, roles, users,
interviewTypes, meetingTypes, companies, inbox, emails, publishPlatforms, publishings, tasks, rbacRoles,
// enterprise
sourceMeta, inboxSources, processingStatuses, resumeStatuses, inbox, emails,
publishPlatforms, publishings, tasks, savedSearches, evalTemplates,
rbacModules, permTypes, rbacRoles, aiModules, aiPrompts,
recentlyViewed, favorites, recentlyViewed, favorites,
// helpers // helpers
fmtDate, fmtShort, relTime, initials, avatarColor, int, pick, atsRecommendationClass, fmtDate, fmtShort, relTime, initials, avatarColor, int, pick, atsRecommendationClass,
money: n => '$' + n.toLocaleString('en-US'), money, moneyK, byId, getJob, getCandidate, getManager, getRecruiter, getRecruiterByName,
moneyK: n => '$' + Math.round(n / 1000) + 'k', };
getJob: id => jobs.find(j => j.id === id),
getCandidate: id => candidates.find(c => c.id === id),
getManager: id => managers.find(m => m.id === id),
getRecruiter: id => recruiters.find(r => r.id === id),
getRecruiterByName: n => recruiters.find(r => r.name === n)
};
})();

View File

@ -0,0 +1,99 @@
/* ============================================================
seedQueries.js seed data served through the query cache.
Two jobs:
1. Make swapping a screen to a real endpoint a ONE-LINE change. A screen does
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
and the day GET /jobs/fetch exists that becomes
const { data: jobs = [] } = useQuery({ queryKey: qk.jobs.list(f),
queryFn: () => jobsApi.list(f) })
with the component body, the JSX and the table config untouched.
2. Give the seed a mutation model. The prototype mutated the DB global in
place and called Router.reload(); here the CACHE is the store, and every
mutation is a setQueryData that re-renders exactly the subscribed screens.
============================================================ */
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { qk } from '../lib/queryKeys'
import * as seed from './seed'
/**
* gcTime: Infinity is load-bearing, not a micro-optimisation. These buckets are
* mutable app state, not server state if a mutated bucket were garbage
* collected after sitting off-screen it would silently revert to the pristine
* seed on the next mount, which looks like data loss and is miserable to debug.
*/
export function seedQuery(bucket) {
return {
queryKey: qk.seed[bucket](),
queryFn: async () => seed[bucket],
staleTime: Infinity,
gcTime: Infinity,
retry: false,
}
}
/** Buckets pre-populated at boot because the SHELL reads them before any route mounts. */
export const SHELL_BUCKETS = ['jobs', 'notifications', 'messages', 'tasks', 'inbox']
/** Everything the cache owns. The reference lists are imported directly instead. */
export const SEED_BUCKETS = [
...SHELL_BUCKETS,
'candidates', 'interviews', 'assessments', 'offers', 'activity', 'emails',
'publishings', 'rbacRoles', 'recruiters', 'managers', 'users',
]
/**
* Seed the shell's buckets before first render so the sidebar badges and the
* topbar dropdowns paint with real counts instead of popping in a frame later.
* This is the "cache initialized" step, called once from main.jsx.
*/
export function initializeCache(queryClient) {
for (const bucket of SHELL_BUCKETS) {
queryClient.setQueryData(qk.seed[bucket](), seed[bucket])
}
// Runtime buckets the prototype kept on DB. `favorites` and `recentlyViewed`
// restore from localStorage — a "recently viewed" list that empties on every
// refresh is worse than not having one.
queryClient.setQueryData(qk.seed.favorites(), readPersisted('tf-favorites', seed.favorites))
queryClient.setQueryData(qk.seed.recentlyViewed(), readPersisted('tf-recent', []))
}
function readPersisted(key, fallback) {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
} catch {
return fallback
}
}
export function persist(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* ignore */
}
}
/**
* Mutate a seed bucket. Returns `update(fn)` where fn maps the old value to the
* new one the same shape as a React setState updater.
*
* const updateCandidates = useSeedMutation('candidates')
* updateCandidates(cs => cs.map(c => c.id === id ? { ...c, stage } : c))
*/
export function useSeedMutation(bucket) {
const qc = useQueryClient()
return useCallback(
(updater) => {
const key = qk.seed[bucket]()
qc.setQueryData(key, (old) => updater(old ?? seed[bucket]))
return qc.getQueryData(key)
},
[qc, bucket],
)
}

View File

@ -0,0 +1,113 @@
/* ============================================================
apiClient.js the one HTTP entry point.
Attaches the bearer token, renews proactively inside the skew window, and
retries exactly once on a 401. Never loops: if the retry also 401s, the
session is over and the expired handler fires.
============================================================ */
import { ApiError, parseDetail, SessionExpiredError } from './errors'
import { getAccessToken, isExpiring } from './tokenStore'
import { refreshSession } from './refresh'
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
// AuthProvider registers this so the module never has to import the router.
let onSessionExpired = () => {}
export function setSessionExpiredHandler(fn) {
onSessionExpired = fn
}
function buildUrl(path, params) {
const url = new URL(`${API_BASE}${path}`, window.location.origin)
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v)
}
}
return url.toString()
}
/**
* @param {object} opts
* @param {boolean} [opts.auth=true] attach the session bearer token
* @param {string} [opts.token] an explicit token (the password-reset flow);
* suppresses all refresh behaviour
*/
export async function request(
path,
{ method = 'GET', body, params, auth = true, token, signal } = {},
) {
// PROACTIVE renewal. Coalesced by single-flight, so a screen firing six
// queries at once on an expiring token still triggers exactly one refresh.
if (auth && !token && isExpiring()) {
try {
await refreshSession()
} catch {
// Fall through — the 401 path below makes the final call. This keeps a
// transient network blip from logging the user out.
}
}
const send = async () => {
const headers = { Accept: 'application/json' }
if (body != null) headers['Content-Type'] = 'application/json'
const bearer = token ?? (auth ? getAccessToken() : null)
if (bearer) headers.Authorization = `Bearer ${bearer}`
return fetch(buildUrl(path, params), {
method,
headers,
signal,
body: body != null ? JSON.stringify(body) : undefined,
})
}
let res
try {
res = await send()
} catch (err) {
if (err?.name === 'AbortError') throw err
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
}
// REACTIVE renewal — exactly one retry.
if (res.status === 401 && auth && !token) {
try {
await refreshSession()
} catch (err) {
if (err instanceof SessionExpiredError) onSessionExpired()
throw err
}
res = await send()
if (res.status === 401) {
// A fresh access token still 401s: the user was deactivated or soft-deleted
// server-side (get_current_user rejects both). Signing them out is correct.
onSessionExpired()
throw new ApiError('Session expired', 401, 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 const get = (path, params, opts) => request(path, { ...opts, params })
export const post = (path, body, opts) => request(path, { ...opts, method: 'POST', body })
export const put = (path, body, opts) => request(path, { ...opts, method: 'PUT', body })
export const del = (path, opts) => request(path, { ...opts, method: 'DELETE' })

View File

@ -2,11 +2,14 @@
charts.js Lightweight Canvas chart engine (no libraries) charts.js Lightweight Canvas chart engine (no libraries)
Exposes global `Charts` with: line, bar, groupedBar, doughnut, Exposes global `Charts` with: line, bar, groupedBar, doughnut,
area, horizontalBar, sparkline area, horizontalBar, sparkline
============================================================ */
(function () {
'use strict';
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } Retained verbatim from the prototype per ADR 0013 the only edits are the
IIFE wrapper becoming an ES module and `window.Charts` becoming a default
export. The engine still reads --c1..--c8 and --border/--text-3/--bg-elev
live from CSS, so it re-themes itself with no React involvement.
============================================================ */
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
/* Series colours live in CSS (--c1..--c8) so light and dark each get a /* Series colours live in CSS (--c1..--c8) so light and dark each get a
set tuned to their surface. Read live rather than cached: App.setTheme set tuned to their surface. Read live rather than cached: App.setTheme
@ -336,12 +339,10 @@
canvas.onmouseleave = hideTip; canvas.onmouseleave = hideTip;
} }
window.Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, legend, token: css }; // `legend` is gone: it returned an HTML string, which is now the
// Live getter: each read reflects the active theme's --c1..--c8. // <ChartLegend/> component in src/ui/Chart.jsx.
Object.defineProperty(window.Charts, 'PALETTE', { get: palette, enumerable: true }); const Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, token: css };
// Live getter: each read reflects the active theme's --c1..--c8.
Object.defineProperty(Charts, 'PALETTE', { get: palette, enumerable: true });
function legend(items) { export default Charts;
return `<div class="chart-legend">${items.map(it =>
`<span class="legend-item"><span class="legend-dot" style="background:${it.color}"></span>${it.label}</span>`).join('')}</div>`;
}
})();

View File

@ -0,0 +1,69 @@
/* ============================================================
errors.js the API error type and FastAPI `detail` unwrapping.
Lifted verbatim from the original src/api.js. FastAPI returns errors as
{detail: ...} where `detail` is a string for HTTPException and an array of
{loc, msg, type} objects for request-validation failures parseDetail
already handles both shapes, so it moves unchanged.
============================================================ */
export class ApiError extends Error {
constructor(message, status, body) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
/** Raised when the refresh token itself is rejected — the session is over. */
export class SessionExpiredError extends Error {
constructor(message = 'Your session has expired. Please sign in again.') {
super(message)
this.name = 'SessionExpiredError'
}
}
export 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
}
}

View File

@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError, SessionExpiredError } from './errors'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// A recruiter re-entering a screen within the minute gets cache, no spinner.
staleTime: 60_000,
gcTime: 15 * 60_000,
// This is an all-day tool. The prototype had no refetch-on-focus, and
// restriping every table on alt-tab would be a visible behaviour change.
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: (count, err) => {
if (err instanceof SessionExpiredError) return false
// 401 is already handled by apiClient's refresh-and-retry; re-running
// the query would just repeat work. 403/404/422 will never succeed.
if (err instanceof ApiError && err.status >= 400 && err.status < 500) return false
return count < 2
},
retryDelay: (i) => Math.min(1000 * 2 ** i, 8000),
},
mutations: { retry: 0 },
},
})

View File

@ -0,0 +1,48 @@
/* Query key convention: [domain, scope, ...params], always an array, params
always last as a single object so `invalidateQueries({queryKey:['users']})`
catches every scope under a domain. */
export const qk = {
auth: { me: () => ['auth', 'me'] },
// --- real backend endpoints ---
users: {
all: () => ['users'],
list: (p = {}) => ['users', 'list', p],
},
roles: {
all: () => ['roles'],
list: () => ['roles', 'list'],
permissions: () => ['roles', 'permissions'],
tags: () => ['roles', 'permission-tags'],
},
mailbox: {
all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'],
},
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every
// mutation is a setQueryData. See data/seedQueries.js.
seed: {
all: () => ['seed'],
jobs: () => ['seed', 'jobs'],
candidates: () => ['seed', 'candidates'],
interviews: () => ['seed', 'interviews'],
assessments: () => ['seed', 'assessments'],
offers: () => ['seed', 'offers'],
tasks: () => ['seed', 'tasks'],
notifications: () => ['seed', 'notifications'],
messages: () => ['seed', 'messages'],
activity: () => ['seed', 'activity'],
inbox: () => ['seed', 'inbox'],
emails: () => ['seed', 'emails'],
publishings: () => ['seed', 'publishings'],
rbacRoles: () => ['seed', 'rbacRoles'],
recruiters: () => ['seed', 'recruiters'],
managers: () => ['seed', 'managers'],
users: () => ['seed', 'users'],
favorites: () => ['seed', 'favorites'],
recentlyViewed: () => ['seed', 'recentlyViewed'],
},
}

View File

@ -0,0 +1,88 @@
/* ============================================================
refresh.js single-flight token refresh.
WHY SINGLE-FLIGHT IS MANDATORY, NOT AN OPTIMISATION
---------------------------------------------------
POST /users/refresh calls serialize_token(create_access_token(user),
create_refresh_token(user), user) it mints a NEW refresh token every time
and there is no reuse detection or jti tracking on the server, so the old one
stays valid until its 7-day exp.
Two concurrent refreshes therefore BOTH succeed and return two different
valid pairs. Whichever setSession() lands second wins; requests already in
flight carry a token from the losing pair. The result is intermittent 401s
that look random and are close to undiagnosable from logs.
So: one promise per tab, and a Web Locks mutex across tabs.
============================================================ */
import { SessionExpiredError } from './errors'
import {
getSession,
getRefreshToken,
setSession,
clearSession,
reloadFromStorage,
} from './tokenStore'
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
let inFlight = null
/**
* Refresh the session. Concurrent callers all await the SAME promise, so at most
* one POST /users/refresh is outstanding per tab at any moment.
*/
export function refreshSession() {
if (inFlight) return inFlight
inFlight = run().finally(() => {
inFlight = null
})
return inFlight
}
async function post(refreshToken) {
let res
try {
res = await fetch(`${API_BASE}/users/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
// The backend takes the refresh token in the JSON BODY — not a cookie,
// not an Authorization header (backend/users/app.py: TokenRefresh).
body: JSON.stringify({ refresh_token: refreshToken }),
})
} catch {
// Network failure is not an expired session — don't destroy a good session
// because the wifi dropped. Surface it and let the caller retry later.
throw new Error('Unable to reach the server while renewing your session.')
}
if (!res.ok) {
// 401 "Invalid or expired refresh token", or the user was deactivated.
clearSession()
throw new SessionExpiredError()
}
// Rotates BOTH tokens; setSession preserves `permissions` from /users/me.
return setSession(await res.json())
}
async function run() {
reloadFromStorage()
const token = getRefreshToken()
if (!token) throw new SessionExpiredError()
// Cross-tab single flight. Without it, five open tabs hitting an expired token
// means five rotations of the same refresh token: four pairs are orphaned and
// four tabs end up holding a stale access token.
if (typeof navigator !== 'undefined' && navigator.locks) {
return navigator.locks.request('tf-auth-refresh', async () => {
reloadFromStorage()
const current = getRefreshToken()
// Another tab rotated while we waited for the lock — reuse its result
// instead of spending our now-superseded token.
if (current && current !== token) return getSession()
return post(current ?? token)
})
}
return post(token)
}

View File

@ -0,0 +1,121 @@
/* ============================================================
tokenStore.js the session record, its expiry math, and cross-tab sync.
A module-level singleton rather than React state, because apiClient must read
the access token from plain async code that has no hooks available. React
binds to it through useSyncExternalStore (see auth/AuthProvider.jsx), which
works because every write replaces `cached` wholesale the snapshot identity
only changes when the session actually changes.
Storage key stays `tf-auth` so anyone already signed in stays signed in.
============================================================ */
const KEY = 'tf-auth'
// Renew this far ahead of expiry. Covers clock drift between browser and server
// and, more importantly, means a request never has to eat a guaranteed 401 first.
export const CLOCK_SKEW_MS = 60_000
function readRaw() {
try {
return JSON.parse(localStorage.getItem(KEY)) || null
} catch {
return null
}
}
let cached = readRaw()
const subscribers = new Set()
function emit() {
subscribers.forEach((fn) => fn())
}
function persist() {
try {
localStorage.setItem(KEY, JSON.stringify(cached))
} catch {
/* quota or private mode — the in-memory session still works for this tab */
}
}
export function getSession() {
return cached
}
export function getAccessToken() {
return cached?.access_token ?? null
}
export function getRefreshToken() {
return cached?.refresh_token ?? null
}
export function subscribe(fn) {
subscribers.add(fn)
return () => subscribers.delete(fn)
}
/** Re-read from storage. Another tab may have rotated the pair since we last looked. */
export function reloadFromStorage() {
cached = readRaw()
return cached
}
export function isExpiring(skew = CLOCK_SKEW_MS) {
if (!cached?.access_token) return false
// Sessions written before this field existed can't be reasoned about; let the
// reactive 401 path handle those rather than refreshing on every request.
if (!cached.expires_at) return false
return Date.now() >= cached.expires_at - skew
}
/**
* Store a login/signup/refresh response.
*
* The `data` MERGE is load-bearing. /users/login and /users/refresh return a
* user record WITHOUT `permissions` only GET /users/me resolves those
* (backend/users/serializers.py: serialize_user's with_permissions defaults to
* False). A naive assignment would therefore wipe the permission list on the
* first background refresh, and every permission-gated nav item would vanish
* mid-session.
*/
export function setSession(res) {
cached = {
access_token: res.access_token,
refresh_token: res.refresh_token,
expires_in: res.expires_in,
expires_at: Date.now() + (res.expires_in ?? 1800) * 1000,
data: { ...(cached?.data ?? {}), ...(res.data ?? {}) },
}
persist()
emit()
return cached
}
/** Merge the GET /users/me payload — this is what puts `permissions` on the session. */
export function mergeUser(user) {
if (!cached || !user) return cached
cached = { ...cached, data: { ...cached.data, ...user } }
persist()
emit()
return cached
}
export function clearSession() {
cached = null
try {
localStorage.removeItem(KEY)
} catch {
/* ignore */
}
emit()
}
// Cross-tab: sign-out and token rotation both propagate. `e.key === null` is a
// storage.clear() from another tab.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== KEY && e.key !== null) return
cached = readRaw()
emit()
})
}

View File

@ -1,14 +1,35 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@shared-css' import { QueryClientProvider } from '@tanstack/react-query'
import './auth.css' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { initTheme } from './theme'
import App from './App'
// The frozen design contract (ADR 0013 §1): 1,269 lines, 93 tokens, dual
// themes, WCAG 2.1 AA verified across 23 routes. Content-frozen new CSS may
// only use existing var(--) tokens.
import './styles/styles.css'
import './styles/auth.css'
import App from './App'
import ThemeProvider, { initTheme } from './theme/ThemeProvider'
import ToastProvider from './ui/Toast'
import { queryClient } from './lib/queryClient'
import { initializeCache } from './data/seedQueries'
// Before render: theme first so there is no light-mode flash, then the cache so
// the shell's badges and dropdowns paint with real counts on the first frame
// instead of popping in once a query resolves.
initTheme() initTheme()
initializeCache(queryClient)
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<App /> <App />
</ToastProvider>
</ThemeProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -3,7 +3,8 @@ import { Link, useSearchParams } from 'react-router-dom'
import AuthLayout from '../components/AuthLayout' import AuthLayout from '../components/AuthLayout'
import Alert from '../components/Alert' import Alert from '../components/Alert'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { confirmEmail, resendConfirmEmail, friendlyAuthError } from '../api' import { confirmEmail, resendConfirmEmail } from '../api/auth'
import { friendlyAuthError } from '../lib/errors'
const titles = { const titles = {
verifying: 'Confirming your email', verifying: 'Confirming your email',
@ -88,7 +89,7 @@ export default function ConfirmEmail() {
foot={ foot={
<> <>
Back to{' '} Back to{' '}
<Link className="link-btn" to="/login"> <Link className="link-btn" to="/auth/login">
Sign in Sign in
</Link> </Link>
</> </>
@ -103,7 +104,7 @@ export default function ConfirmEmail() {
)} )}
{state === 'success' && ( {state === 'success' && (
<Link className="btn btn-primary btn-block" to="/login" style={{ textDecoration: 'none' }}> <Link className="btn btn-primary btn-block" to="/auth/login" style={{ textDecoration: 'none' }}>
Sign in Sign in
</Link> </Link>
)} )}

View File

@ -6,12 +6,8 @@ import Alert from '../components/Alert'
import OtpInput from '../components/OtpInput' import OtpInput from '../components/OtpInput'
import Countdown, { useResendGate } from '../components/Countdown' import Countdown, { useResendGate } from '../components/Countdown'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { import { forgetPassword, verifyForgetCode, setNewPassword } from '../api/auth'
forgetPassword, import { friendlyAuthError } from '../lib/errors'
verifyForgetCode,
setNewPassword,
friendlyAuthError,
} from '../api'
function pickTiming(payload) { function pickTiming(payload) {
const src = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {} const src = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {}
@ -158,13 +154,13 @@ export default function ForgotPassword() {
subtitle={subtitles[step]} subtitle={subtitles[step]}
foot={ foot={
step === 4 ? ( step === 4 ? (
<Link className="link-btn" to="/login"> <Link className="link-btn" to="/auth/login">
Back to sign in Back to sign in
</Link> </Link>
) : ( ) : (
<> <>
Remembered it?{' '} Remembered it?{' '}
<Link className="link-btn" to="/login"> <Link className="link-btn" to="/auth/login">
Sign in Sign in
</Link> </Link>
</> </>
@ -277,7 +273,7 @@ export default function ForgotPassword() {
)} )}
{step === 4 && ( {step === 4 && (
<Link className="btn btn-primary btn-block" to="/login" style={{ textDecoration: 'none' }}> <Link className="btn btn-primary btn-block" to="/auth/login" style={{ textDecoration: 'none' }}>
Sign in Sign in
</Link> </Link>
)} )}

View File

@ -1,17 +1,21 @@
import { Link } from 'react-router-dom' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import AuthLayout, { useFormState } from '../components/AuthLayout' import AuthLayout, { useFormState } from '../components/AuthLayout'
import PasswordField from '../components/PasswordField' import PasswordField from '../components/PasswordField'
import Alert from '../components/Alert' import Alert from '../components/Alert'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { login, friendlyAuthError } from '../api' import { friendlyAuthError } from '../lib/errors'
import { setSession } from '../auth' import { useAuth } from '../auth/AuthContext'
function goToApp() {
window.location.assign('/index.html#dashboard')
}
export default function Login() { export default function Login() {
const form = useFormState({ email: '', password: '' }) const form = useFormState({ email: '', password: '' })
const { signIn } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [params] = useSearchParams()
// Where the guard bounced us from, so a deep link survives the login round-trip.
const from = location.state?.from?.pathname || '/dashboard'
const expired = params.get('expired') === '1'
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault() e.preventDefault()
@ -24,9 +28,9 @@ export default function Login() {
form.setAlert(null) form.setAlert(null)
form.setBusy(true) form.setBusy(true)
try { try {
const res = await login(form.values.email.trim(), form.values.password) await signIn(form.values.email.trim(), form.values.password)
setSession(res) // In-SPA now: the old full page load out to /index.html#dashboard is gone.
goToApp() navigate(from, { replace: true })
} catch (err) { } catch (err) {
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') }) form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
form.setBusy(false) form.setBusy(false)
@ -40,7 +44,7 @@ export default function Login() {
foot={ foot={
<> <>
New here?{' '} New here?{' '}
<Link className="link-btn" to="/signup"> <Link className="link-btn" to="/auth/signup">
Create an account Create an account
</Link> </Link>
</> </>
@ -48,6 +52,9 @@ export default function Login() {
> >
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
<Alert type={form.alert?.type}>{form.alert?.message}</Alert> <Alert type={form.alert?.type}>{form.alert?.message}</Alert>
{!form.alert && expired ? (
<Alert type="danger">Your session expired. Please sign in again.</Alert>
) : null}
<div className="form-field"> <div className="form-field">
<label htmlFor="login-email"> <label htmlFor="login-email">
@ -78,7 +85,7 @@ export default function Login() {
/> />
<div className="auth-row-end"> <div className="auth-row-end">
<Link className="link-btn" to="/forgot-password"> <Link className="link-btn" to="/auth/forgot-password">
Forgot password? Forgot password?
</Link> </Link>
</div> </div>

View File

@ -1,19 +1,17 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import AuthLayout, { useFormState } from '../components/AuthLayout' import AuthLayout, { useFormState } from '../components/AuthLayout'
import PasswordField from '../components/PasswordField' import PasswordField from '../components/PasswordField'
import Alert from '../components/Alert' import Alert from '../components/Alert'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { useResendGate } from '../components/Countdown' import { useResendGate } from '../components/Countdown'
import { signup, resendConfirmEmail, friendlyAuthError, ApiError } from '../api' import { signup, resendConfirmEmail } from '../api/auth'
import { setSession } from '../auth' import { friendlyAuthError, ApiError } from '../lib/errors'
import { setSession } from '../lib/tokenStore'
function goToApp() {
window.location.assign('/index.html#dashboard')
}
export default function Signup() { export default function Signup() {
const form = useFormState({ name: '', email: '', password: '', confirm: '' }) const form = useFormState({ name: '', email: '', password: '', confirm: '' })
const navigate = useNavigate()
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const [resendAfter, setResendAfter] = useState(null) const [resendAfter, setResendAfter] = useState(null)
const [startedAt, setStartedAt] = useState(null) const [startedAt, setStartedAt] = useState(null)
@ -69,7 +67,7 @@ export default function Signup() {
return return
} }
setSession(res) setSession(res)
goToApp() navigate('/dashboard', { replace: true })
} catch (err) { } catch (err) {
// 502 means the account was created but the email failed offer a resend // 502 means the account was created but the email failed offer a resend
// rather than a dead end. // rather than a dead end.
@ -98,7 +96,7 @@ export default function Signup() {
foot={ foot={
<> <>
Already confirmed?{' '} Already confirmed?{' '}
<Link className="link-btn" to="/login"> <Link className="link-btn" to="/auth/login">
Sign in Sign in
</Link> </Link>
</> </>
@ -132,7 +130,7 @@ export default function Signup() {
foot={ foot={
<> <>
Already have an account?{' '} Already have an account?{' '}
<Link className="link-btn" to="/login"> <Link className="link-btn" to="/auth/login">
Sign in Sign in
</Link> </Link>
</> </>

View File

@ -0,0 +1,32 @@
import { useState } from 'react'
import Chat from '../app/ai/Chat'
import { Icon } from '../ui/primitives'
export default function AiAssistant() {
// Bumping the key resets the transcript the old AI.newChat().
const [resetKey, setResetKey] = useState(0)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">AI Assistant</h1>
<p className="page-sub">Your recruiting copilot powered by AI (interface preview)</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending">
<span className="pulse" />Model endpoint · Not connected
</span>
<button className="btn btn-secondary" onClick={() => setResetKey((k) => k + 1)}>
<Icon name="plus" /> New Chat
</button>
</div>
</div>
<div className="card">
<div className="card-body">
<Chat resetKey={resetKey} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import { useState } from 'react'
import Modal from '../ui/Modal'
import { Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { aiModules } from '../data/seed'
export default function AiStudio() {
const { toast } = useToast()
const [detail, setDetail] = useState(null)
const betaCount = aiModules.filter((m) => m.status === 'Beta').length
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">AI Studio</h1>
<p className="page-sub">
Next-generation AI modules designed and API-ready for backend integration
</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />{betaCount} in Beta</span>
</div>
</div>
<div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}>
<div className="ai-logo" style={{ margin: 0, width: 56, height: 56 }}><Icon name="sparkles" /></div>
<div style={{ flex: 1, minWidth: 220 }}>
<h2 style={{ fontSize: 19, marginBottom: 4 }}>Everything is API-ready</h2>
<p style={{ opacity: 0.88 }}>
Each module below ships with a complete, production-grade interface. Connect your model
endpoint to activate them no UI work required.
</p>
</div>
<button className="btn btn-on-brand" onClick={() => toast('Integration guide opened', 'info')}>
<Icon name="external" /> Integration Guide
</button>
</div>
</div>
<div className="grid g-3">
{aiModules.map((m) => (
<div key={m.name} className="card" style={{ cursor: 'pointer' }} onClick={() => setDetail(m)}>
<div className="card-body">
<div className="flex items-center" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<span className={`kpi-icn ${m.cls}`} style={{ width: 46, height: 46, borderRadius: 13 }}>
<Icon name={m.icon} />
</span>
<Badge className={m.status === 'Beta' ? 'b-indigo' : 'b-gray'}>{m.status}</Badge>
</div>
<div className="lr-title" style={{ fontSize: 15 }}>{m.name}</div>
<div className="lr-sub" style={{ marginTop: 5, lineHeight: 1.5 }}>{m.desc}</div>
<div style={{ marginTop: 14, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>
{m.status === 'Beta' ? 'Try it' : 'Join waitlist'} <Icon name="arrow-right" />
</div>
</div>
</div>
))}
</div>
{detail && (
<Modal
title={detail.name}
subtitle={`${detail.status} · AI Module`}
size="modal-lg"
onClose={() => setDetail(null)}
footer={<button className="btn btn-secondary" onClick={() => setDetail(null)}>Close</button>}
>
<div className="flex items-center gap-16" style={{ marginBottom: 18 }}>
<span className={`kpi-icn ${detail.cls}`} style={{ width: 56, height: 56, borderRadius: 16 }}>
<Icon name={detail.icon} />
</span>
<div>
<div className="fw-600" style={{ fontSize: 16 }}>{detail.name}</div>
<div className="text-muted">{detail.desc}</div>
</div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>API Contract (preview)</div>
<pre className="resume-thumb" style={{ maxHeight: 'none' }}>
{`POST /api/ai/${detail.name.toLowerCase().replace(/ /g, '-')}
{
"context": { "jobId": "JOB-1001", "candidateIds": [...] },
"options": { "model": "claude-opus", "stream": true }
}
200 OK
{
"result": { ... },
"usage": { "tokens": 1240 }
}`}
</pre>
</div>
</div>
<p className="text-muted text-sm" style={{ marginTop: 14 }}>
<Icon name="lock" /> This features UI is complete. Backend wiring is the only remaining step.
</p>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,171 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { analytics as a } from '../data/seed'
export default function Analytics() {
const { toast } = useToast()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const trend = useMemo(
() => ({
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] },
],
}),
[],
)
const apps = useMemo(() => ({ labels: a.hiringTrend.labels, data: a.hiringTrend.applications }), [])
const source = useMemo(
() => ({
labels: a.sources.map((s) => s.source),
data: a.sources.map((s) => s.count),
centerValue: candidates.length,
centerLabel: 'Total',
}),
[candidates.length],
)
const offer = useMemo(() => {
const { accepted, pending, declined } = a.offerAcceptance
return {
labels: ['Accepted', 'Pending', 'Declined'],
data: [accepted, pending, declined],
colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],
centerValue: `${Math.round((accepted / (accepted + declined || 1)) * 100)}%`,
centerLabel: 'Accept rate',
}
}, [])
const pipeline = useMemo(
() => ({
labels: a.pipeline.map((p) => p.stage),
data: a.pipeline.map((p) => p.count),
colors: Charts.PALETTE,
}),
[],
)
const dept = useMemo(
() => ({ labels: a.departments.map((d) => d.dept), data: a.departments.map((d) => d.apps) }),
[],
)
const rec = useMemo(() => {
const top = [...recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8)
return { labels: top.map((r) => r.name), data: top.map((r) => r.hires) }
}, [recruiters])
const tth = useMemo(
() => ({
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }],
}),
[],
)
const ttf = useMemo(
() => ({
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }],
}),
[],
)
const trendLegend = useMemo(
() => [
{ label: 'Applications', color: Charts.PALETTE[4] },
{ label: 'Hires', color: Charts.PALETTE[0] },
],
[],
)
const sourceLegend = useMemo(
() => a.sources.map((s, i) => ({ label: s.source, color: Charts.PALETTE[i % Charts.PALETTE.length] })),
[],
)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Analytics</h1>
<p className="page-sub">Deep-dive metrics across your recruitment funnel</p>
</div>
<div className="page-head-actions">
<div className="pill-tabs">
<span className="pill-tab">Week</span>
<span className="pill-tab active">Month</span>
<span className="pill-tab">Quarter</span>
</div>
<button className="btn btn-secondary" onClick={() => toast('Analytics exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head"><div><h3>Hiring Trend</h3><span className="ch-sub">Hires vs applications</span></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="line" data={trend} height={260} /></div>
<ChartLegend items={trendLegend} />
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Applications Received</h3><span className="ch-sub">Monthly volume</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={apps} height={260} /></div></div>
</div>
</div>
<div className="grid g-3 mb-18">
<div className="card">
<div className="card-head"><div><h3>Source Breakdown</h3></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="doughnut" data={source} height={220} /></div>
<ChartLegend items={sourceLegend} />
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Offer Acceptance</h3></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="doughnut" data={offer} height={220} /></div>
<div className="chart-legend">
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--success)' }} />Accepted</span>
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--warning)' }} />Pending</span>
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--danger)' }} />Declined</span>
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Pipeline Distribution</h3></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipeline} height={260} /></div></div>
</div>
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head"><div><h3>Applications by Department</h3><span className="ch-sub">Volume per team</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={dept} height={300} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recruiter Performance</h3><span className="ch-sub">Hires by recruiter (top 8)</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={rec} height={300} /></div></div>
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head"><div><h3>Time to Hire</h3><span className="ch-sub">Days, monthly average</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={tth} height={240} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Time to Fill</h3><span className="ch-sub">Days, monthly average</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={ttf} height={240} /></div></div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,248 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { candidates as allCandidates, fmtDate, fmtShort, int } from '../data/seed'
const SECTIONS = ['Problem Solving', 'Code Quality', 'Communication', 'Time Management']
export default function Assessments() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: assessments = [] } = useQuery(seedQuery('assessments'))
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [assigning, setAssigning] = useState(false)
const stats = useMemo(() => {
const scored = assessments.filter((a) => a.score)
return {
total: assessments.length,
completed: assessments.filter((a) => a.status === 'Completed').length,
pending: assessments.filter((a) => ['Pending', 'In Progress'].includes(a.status)).length,
avg: Math.round(scored.reduce((s, a) => s + a.score, 0) / (scored.length || 1)),
}
}, [assessments])
const types = useMemo(() => [...new Set(assessments.map((a) => a.type))], [assessments])
const rows = useMemo(
() =>
assessments.filter((a) => {
if (status && a.status !== status) return false
if (type && a.type !== type) return false
if (q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[assessments, q, status, type],
)
// Section scores were generated inline at render in the prototype, so they
// reshuffled on every repaint. Derived per assessment id and memoised here.
const sectionScores = useMemo(
() => (viewing ? SECTIONS.map((s) => ({ label: s, score: int(60, 98) })) : []),
[viewing],
)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (a) => (
<div className="user-cell">
<Avatar name={a.candidate} initials={a.initials} color={a.color} />
<div>
<div className="cell-primary">{a.candidate}</div>
<div className="cell-sub">{a.jobTitle}</div>
</div>
</div>
),
},
{
key: 'type', label: 'Assessment', sortable: true,
render: (a) => (
<>
<div className="cell-primary text-sm">{a.type}</div>
<div className="cell-sub">{a.duration}</div>
</>
),
},
{ key: 'assigned', label: 'Assigned', sortable: true, sortValue: (a) => a.assigned.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.assigned)}</span> },
{ key: 'due', label: 'Due', sortable: true, sortValue: (a) => a.due.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.due)}</span> },
{ key: 'score', label: 'Score', sortable: true, align: 'center', render: (a) => (a.score !== null ? <ScoreChip score={a.score} /> : <span className="text-muted"></span>) },
{ key: 'status', label: 'Status', sortable: true, render: (a) => <Badge>{a.status}</Badge> },
{
key: '_a', label: 'Actions', align: 'right',
render: (a) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(a)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Remind" onClick={() => toast(`Reminder sent to ${a.candidate}`, 'info')}><Icon name="mail" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Assessments</h1>
<p className="page-sub">Coding tests, take-homes, and evaluations</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setAssigning(true)}>
<Icon name="plus" /> Assign Assessment
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Assigned" value={stats.total} icon="file" tone="i-indigo" />
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
<KpiCard label="In Progress / Pending" value={stats.pending} icon="clock" tone="i-amber" />
<KpiCard label="Average Score" value={`${stats.avg}%`} icon="target" tone="i-teal" />
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or assessment…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Completed', 'In Progress', 'Pending', 'Expired'].map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{types.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && (
<Modal
title="Assessment Result"
subtitle={viewing.id}
onClose={() => setViewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setViewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => {
const id = viewing.candidateId
setViewing(null)
navigate('/candidates', { state: { openCandidate: id } })
}}
>
View Candidate
</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={viewing.candidate} initials={viewing.initials} color={viewing.color} className="avatar-lg" />
<div>
<div className="ph-name" style={{ fontSize: 17 }}>{viewing.candidate}</div>
<div className="ph-role">{viewing.type} · {viewing.jobTitle}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{viewing.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Type</div><div className="iv">{viewing.type}</div></div>
<div className="info-item"><div className="il">Duration</div><div className="iv">{viewing.duration}</div></div>
<div className="info-item"><div className="il">Assigned</div><div className="iv">{fmtDate(viewing.assigned)}</div></div>
<div className="info-item"><div className="il">Due</div><div className="iv">{fmtDate(viewing.due)}</div></div>
</div>
{viewing.score !== null ? (
<>
<div className="divider" />
<div style={{ textAlign: 'center', padding: '10px 0' }}>
<div
style={{
fontSize: 44, fontWeight: 800, letterSpacing: -1,
color: viewing.score >= 70 ? 'var(--success)' : 'var(--warning)',
}}
>
{viewing.score}%
</div>
<div className="text-muted">Overall Score</div>
</div>
<div className="mb-18"><ProgressBar pct={viewing.score} /></div>
<div className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</div>
{sectionScores.map((s) => (
<div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}>
<span style={{ width: 130, fontSize: 13 }}>{s.label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={s.score} /></div>
<b style={{ width: 40, textAlign: 'right' }}>{s.score}%</b>
</div>
))}
</>
) : (
<div className="empty-state">
<Icon name="clock" />
<h3>Assessment not completed</h3>
<p>Results will appear once the candidate submits.</p>
</div>
)}
</Modal>
)}
{assigning && (
<Modal
title="Assign Assessment"
subtitle="Send an evaluation to a candidate"
onClose={() => setAssigning(false)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setAssigning(false)}>Cancel</button>
<button
className="btn btn-primary"
onClick={() => {
setAssigning(false)
toast('Assessment assigned & invite sent', 'success')
}}
>
<Icon name="send" /> Assign
</button>
</>
}
>
<form>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate</label>
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
</div>
<div className="form-field">
<label>Assessment Type</label>
<select>{types.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Time Limit</label>
<select><option>45 min</option><option>60 min</option><option>90 min</option><option>3 days</option></select>
</div>
<div className="form-field col-span-2">
<label>Due Date</label>
<input type="date" />
</div>
</div>
</form>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,149 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { TODAY } from '../data/seed'
const EVENT_COLORS = {
'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',
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
/** The prototype's hand-built month grid, unchanged in behaviour. */
function buildCells(year, month) {
const startDow = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const prevDays = new Date(year, 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(year, month, d) })
while (cells.length % 7 !== 0 || cells.length < 42) {
cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true })
}
return cells.slice(0, 42)
}
export default function Calendar() {
const navigate = useNavigate()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const [{ year, month }, setView] = useState({ year: TODAY.getFullYear(), month: TODAY.getMonth() })
const cells = useMemo(() => buildCells(year, month), [year, month])
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const todayKey = TODAY.toDateString()
const todayIvs = interviews.filter((iv) => iv.when.toDateString() === todayKey)
const step = (delta) =>
setView(({ year: y, month: m }) => {
const next = m + delta
if (next < 0) return { year: y - 1, month: 11 }
if (next > 11) return { year: y + 1, month: 0 }
return { year: y, month: next }
})
const openCandidate = (id) => navigate('/candidates', { state: { openCandidate: id } })
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Calendar</h1>
<p className="page-sub">Interview schedule at a glance</p>
</div>
<div className="page-head-actions">
<div className="flex items-center gap-8">
<button className="btn btn-icon btn-secondary" onClick={() => step(-1)} aria-label="Previous month">
<Icon name="chevron-left" />
</button>
<span className="fw-600" style={{ minWidth: 140, textAlign: 'center' }}>{monthName}</span>
<button className="btn btn-icon btn-secondary" onClick={() => step(1)} aria-label="Next month">
<Icon name="chevron-right" />
</button>
</div>
<button
className="btn btn-primary"
onClick={() => navigate('/interviews', { state: { openSchedule: true } })}
>
<Icon name="plus" /> Schedule
</button>
</div>
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-body">
<div className="cal-grid">
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
{cells.map((c, i) => {
const dayEvents = !c.other && c.date
? interviews.filter((iv) => iv.when.toDateString() === c.date.toDateString())
: []
const isToday = !c.other && c.date && c.date.toDateString() === todayKey
return (
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
<div className="cal-date">{c.day}</div>
{dayEvents.slice(0, 3).map((iv) => (
<div
key={iv.id}
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
title={`${iv.candidate} · ${iv.type}`}
onClick={() => openCandidate(iv.candidateId)}
>
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
</div>
))}
{dayEvents.length > 3 && (
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
)}
</div>
)
})}
</div>
</div>
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head">
<div>
<h3>Today</h3>
<span className="ch-sub">
{TODAY.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
<div className="card-body">
<div className="list-tight">
{todayIvs.length === 0 ? (
<p className="text-muted">No interviews today</p>
) : (
todayIvs.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => openCandidate(iv.candidateId)}
>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,277 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
const { toast } = useToast()
const [tab, setTab] = useState('Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
// The prototype called DB.pick() inline while rendering, so the "previous
// employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
return (
<Modal
title="Candidate Profile"
subtitle={c.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
style={{ marginRight: 'auto' }}
onClick={() => onToggleFav(c)}
>
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
</button>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
<Icon name="mail" /> Message
</button>
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
<div className="info-item"><div className="il">Rating</div><div className="iv"> {c.rating} / 5.0</div></div>
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</>
)}
{tab === 'Resume' && (
<>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{c.name}</h3>
<p className="text-muted">{c.currentTitle} · {c.location}</p>
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div>
<p className="text-muted">
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.
</p>
<div className="form-section-title">Experience</div>
<div className="info-item">
<div className="iv">{c.currentTitle} {c.currentCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2021 Present</div>
</div>
<div className="info-item" style={{ marginTop: 10 }}>
<div className="iv">Associate {priorCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2018 2021</div>
</div>
<div className="form-section-title">Education</div>
<div className="iv">{c.education}</div>
</div>
</div>
<button className="btn btn-secondary" style={{ marginTop: 14 }} onClick={() => toast('Downloading resume.pdf', 'info')}>
<Icon name="download" /> Download PDF
</button>
</>
)}
{tab === 'Timeline' && (
<div className="timeline">
{[
{ icon: 'user-plus', title: 'Application received', meta: 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' },
].map((e) => (
<div className="tl-item" key={e.title}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{e.meta}</div>
<div className="tl-desc">{e.desc}</div>
</div>
))}
</div>
)}
{tab === 'Interview' && (
candidateInterviews.length ? (
<div className="list-tight">
{candidateInterviews.map((iv) => (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.type}</div>
<div className="lr-sub">{fmtDate(iv.when)} · {iv.meeting}</div>
</div>
<div className="lr-right"><Badge>{iv.status}</Badge></div>
</div>
))}
</div>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule an interview to get started.
</EmptyState>
)
)}
{tab === 'Notes' && (
<>
<div className="form-field">
<label>Add a note</label>
<textarea placeholder="Write a private note about this candidate…" />
</div>
<button className="btn btn-primary btn-sm" style={{ margin: '10px 0 18px' }} onClick={() => toast('Note saved', 'success')}>
<Icon name="plus" /> Add Note
</button>
<div className="list-tight">
<div className="list-row">
<Avatar name={c.recruiter} />
<div className="lr-main">
<div className="lr-title">{c.recruiter}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Strong communication skills, great culture fit. Recommend advancing.
</div>
<div className="lr-sub">2 days ago</div>
</div>
</div>
<div className="list-row">
<Avatar name="Asfand Ahmed" initials="AA" />
<div className="lr-main">
<div className="lr-title">Asfand Ahmed</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Reviewed portfolio impressive work. Schedule technical round.
</div>
<div className="lr-sub">4 days ago</div>
</div>
</div>
</div>
</>
)}
{tab === 'Activity' && (
<div className="list-tight">
{[
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
{ icon: 'mail', tone: 'i-blue', text: 'Email sent: Interview invitation', when: '1 day ago' },
{ icon: 'star', tone: 'i-amber', text: `Assessment score updated to ${c.aiScore}%`, when: '2 days ago' },
{ icon: 'user-plus', tone: 'i-purple', text: `Applied for ${c.jobTitle}`, when: fmtDate(c.applied) },
].map((a) => (
<div className="list-row" key={a.text}>
<span className={`kpi-icn ${a.tone}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.text}</div>
<div className="lr-sub">{a.when}</div>
</div>
</div>
))}
</div>
)}
{tab === 'Documents' && (
<div className="list-tight">
{[
{ 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' },
].map((d) => (
<div className="list-row" key={d.n}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div>
<button className="act-btn" onClick={() => toast(`Downloading ${d.n}`, 'info')}>
<Icon name="download" />
</button>
</div>
))}
</div>
)}
{tab === 'Feedback' && (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
const r = recruiters[i]
if (!r) return null
const notes = [
'Excellent technical depth and clear communication.',
'Good problem solving, would benefit from more system design exposure.',
'Solid candidate, positive team energy.',
]
return (
<div className="list-row" key={score}>
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
</div>
<div className="lr-right"><Badge>{score}</Badge></div>
</div>
)
})}
</div>
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
<Icon name="plus" /> Submit Scorecard
</button>
</>
)}
</div>
</Modal>
)
}

View File

@ -0,0 +1,660 @@
/* ============================================================
Candidates the largest screen in the app: a 14-facet filter panel, a
composite relevance sort, a multi-select bulk bar, favourites, a
recently-viewed strip, the ATS-match modal and the 8-tab profile
(CandidateProfile.jsx).
Uses the headless `useDataTable` rather than <DataTable/>, because the
selection column needs to render against a Set this component owns.
============================================================ */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import CandidateProfile from './CandidateProfile'
import { qk } from '../lib/queryKeys'
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
import {
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
} from '../data/seed'
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
const ATS_BANDS = ['85+', '70-84', '<70']
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
const EMPTY_FILTERS = {
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
}
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [selected, setSelected] = useState(() => new Set())
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const [bulkAssigning, setBulkAssigning] = useState(false)
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
const relevance = useCallback((c) => {
const req = (getJob(c.jobId) || {}).skills || []
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
const openProfile = useCallback(
(c) => {
setProfileFor(c)
qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => {
const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12)
persist('tf-recent', next)
return next
})
},
[qc],
)
// Deep links from global search, dashboard, pipeline, calendar, interviews
useEffect(() => {
const st = location.state
if (!st) return
if (st.openAdd) setAdding(true)
if (st.openCandidate) {
const c = candidates.find((x) => x.id === st.openCandidate)
if (c) openProfile(c)
}
}, [location.state, candidates, openProfile])
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
const rows = useMemo(() => {
const f = filters
let list = 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 = 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 (q) {
const term = q.toLowerCase()
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance])
const columns = useMemo(
() => [
{ key: '_sel', label: '' },
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'availability', label: 'Availability' },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
function toggleSelect(id) {
setSelected((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
toast(`${c.name} moved to ${stage}`, 'success')
}
function bulk(action) {
const ids = [...selected]
if (!ids.length) return
if (action === 'email') {
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
setSelected(new Set())
return
}
if (action === 'assign') {
setBulkAssigning(true)
return
}
if (action === 'advance') {
updateCandidates((cs) =>
cs.map((c) => {
if (!selected.has(c.id)) return c
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
const stage = STAGE_ORDER[i + 1]
return { ...c, stage, status: stage }
}),
)
toast(`${ids.length} candidates advanced`, 'success')
}
if (action === 'reject') {
updateCandidates((cs) =>
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
)
toast(`${ids.length} candidates rejected`, 'warning')
}
setSelected(new Set())
}
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
.filter(Boolean)
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Candidates</h1>
<p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · ranked by AI relevance
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
<Icon name="bookmark" /> Save Search
</button>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
</button>
</div>
</div>
{recentChips.length > 0 && (
<div className="flex items-center gap-8" style={{ marginBottom: 14, flexWrap: 'wrap' }}>
<span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar" style={{ display: 'flex' }}>
<span className="checkbox on"><Icon name="check" /></span>
<span className="fw-600">{selected.size} selected</span>
<div style={{ flex: 1 }} />
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
</div>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
<div className="spacer" />
<label className="text-muted text-sm">Sort:</label>
<select className="select" value={sortMode} onChange={(e) => setSortMode(e.target.value)}>
<option value="relevance">AI Relevance</option>
<option value="ats">ATS Score</option>
<option value="recent">Most Recent</option>
<option value="name">Name AZ</option>
</select>
</div>
{showFilters && (
<div
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
</div>
)}
</div>
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<span
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
onClick={() => toggleSelect(c.id)}
role="checkbox"
aria-checked={selected.has(c.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
>
<Icon name="check" />
</span>
</td>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="cell-primary">
{c.name}{' '}
{c.favorite && (
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
)}
</div>
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{c.jobTitle}</div>
<div className="cell-sub">{c.department}</div>
</td>
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
<td style={{ textAlign: 'center' }}>
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
{relevance(c)}%
</span>
</td>
<td><Badge>{c.stage}</Badge></td>
<td style={{ textAlign: 'center' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
<ScoreChip score={c.aiScore} />
</span>
</td>
<td>
<span className="text-sm">{c.availability}</span>
<div className="cell-sub">{c.noticePeriod} notice</div>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
<Icon name="star" />
</button>
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
</div>
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
{bulkAssigning && (
<BulkAssign
count={selected.size}
recruiters={recruiters}
onClose={() => setBulkAssigning(false)}
onSave={(name) => {
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
setBulkAssigning(false)
setSelected(new Set())
toast('Recruiter assigned to selected candidates', 'success')
}}
/>
)}
{adding && (
<AddCandidate
jobs={jobs}
count={candidates.length}
onClose={() => setAdding(false)}
onSave={(c) => {
updateCandidates((cs) => [c, ...cs])
setAdding(false)
toast('Candidate added to pipeline', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
/>
)}
</div>
)
}
function Facet({ label, value, onChange, any, options }) {
return (
<div className="form-field">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{any}</option>
{options.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
)
}
function AtsMatch({ candidate: c, onClose, onProfile }) {
const sub = c.subScores
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 Row = ({ label, val }) => (
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
</div>
)
return (
<Modal
title="ATS Match Analysis"
subtitle={`${c.id} · ${c.jobTitle}`}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-primary" onClick={() => onProfile(c)}>View Full Profile</button>
</>
}
>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
</div>
</div>
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ '--pct': c.aiScore, '--c': ringColor }}>
<div className="ats-val">
<div className="ats-num">{c.aiScore}</div>
<div className="ats-lbl">ATS MATCH</div>
</div>
</div>
</div>
<div>
<Row label="Skills" val={sub.skills} />
<Row label="Experience" val={sub.experience} />
<Row label="Education" val={sub.education} />
<Row label="Keywords" val={sub.keywords} />
<Row label="Location" val={sub.location} />
<Row label="Salary" val={sub.salary} />
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
education, location and salary alignment. Connect an AI model to refine with semantic matching.
</p>
</Modal>
)
}
function BulkAssign({ count, recruiters, onClose, onSave }) {
const [name, setName] = useState(recruiters[0]?.name ?? '')
return (
<Modal
title="Bulk Assign Recruiter"
subtitle={`${count} candidates`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Assign to</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
</Modal>
)
}
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
const open = jobs.filter((j) => j.status === 'Open')
const form = useFormState({
name: '', email: '', phone: '', job: open[0]?.title ?? '',
experience: '3', company: '', source: sources[0], stage: stages[0],
})
function submit() {
const v = form.values
const errors = {}
if (!v.name.trim()) errors.name = 'Required'
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Valid email required'
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
const job = jobs.find((j) => j.title === v.job) || jobs[0]
const score = int(55, 95)
onSave({
id: `CAN-${5001 + count}`,
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
email: v.email, phone: v.phone || '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
currentTitle: job.title, location: job.location,
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
recruiter: job.recruiter, recruiterId: job.recruiterId,
applied: new Date(TODAY), 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',
})
}
const field = (n) => ({ value: form.values[n], onChange: (e) => form.setField(n, e.target.value) })
return (
<Modal
title="Add Candidate"
subtitle="Manually add a candidate to the pipeline"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Add Candidate</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field">
<label>Full Name <span className="req">*</span></label>
<input {...field('name')} className={form.errors.name ? 'err' : ''} placeholder="Jane Doe" />
<FieldError>{form.errors.name}</FieldError>
</div>
<div className="form-field">
<label>Email <span className="req">*</span></label>
<input type="email" {...field('email')} className={form.errors.email ? 'err' : ''} placeholder="jane@email.com" />
<FieldError>{form.errors.email}</FieldError>
</div>
<div className="form-field"><label>Phone</label><input {...field('phone')} placeholder="+1 (555) 000-0000" /></div>
<div className="form-field">
<label>Applied Job <span className="req">*</span></label>
<select {...field('job')}>{open.map((j) => <option key={j.id}>{j.title}</option>)}</select>
</div>
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
<div className="form-field">
<label>Source</label>
<select {...field('source')}>{sources.map((s) => <option key={s}>{s}</option>)}</select>
</div>
<div className="form-field">
<label>Stage</label>
<select {...field('stage')}>{stages.map((s) => <option key={s}>{s}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,333 @@
/* ============================================================
CV Import the UX shape is right; the mechanics are still simulated.
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
the files away, then invented a queue with setInterval-driven progress. That
is preserved deliberately: there is no upload endpoint, no object storage and
no parser behind this yet, so pretending otherwise would be worse than the
honest "processed locally in this demo" label the screen already carries.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
} from '../data/seed'
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
const STEPS = [
{ 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' },
]
export default function CvImport() {
const { toast } = useToast()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateCandidates = useSeedMutation('candidates')
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [duplicateFor, setDuplicateFor] = useState(null)
const timers = useRef(new Set())
useEffect(() => {
const set = timers.current
return () => {
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
set.clear()
}
}, [])
const advance = useCallback((id) => {
const tick = setInterval(() => {
setQueue((q) =>
q.map((item) => {
if (item.id !== id || item.status !== 'Uploading') return item
const progress = Math.min(100, item.progress + int(12, 30))
if (progress >= 100) {
clearInterval(tick)
timers.current.delete(tick)
const done = setTimeout(() => {
setQueue((q2) =>
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
)
timers.current.delete(done)
}, 700 + int(0, 500))
timers.current.add(done)
return { ...item, progress: 100, status: 'Parsing' }
}
return { ...item, progress }
}),
)
}, 220)
timers.current.add(tick)
}, [])
const simulate = useCallback(
(count, isZip) => {
const n = isZip ? 8 : count
const open = jobs.filter((j) => j.status === 'Open')
const items = []
for (let k = 0; k < n; k++) {
const name = `${pick(FIRST)} ${pick(LAST)}`
items.push({
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
name,
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
size: `${int(120, 620)} KB`,
progress: 0,
status: 'Uploading',
atsScore: null,
job: pick(open.length ? open : jobs),
duplicate: Math.random() < 0.18,
imported: false,
})
}
setQueue((q) => [...q, ...items])
items.forEach((i) => advance(i.id))
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
},
[jobs, advance, toast],
)
const doImport = useCallback(
(id) => {
const item = queue.find((x) => x.id === id)
if (!item || item.imported) return
const job = item.job
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: item.name,
initials: initialsOf(item.name),
color: avatarColor(item.name),
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: 'Manual CV Upload',
recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
// Kept verbatim from the prototype, including the literal constants
// this breakdown is fabricated and is flagged as the most misleading
// artefact in the repo (01-repository-assessment.md §2.2).
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
toast(`${item.name} imported → ${job.title}`, 'success')
},
[queue, updateCandidates, toast],
)
function importOne(item) {
if (item.duplicate) setDuplicateFor(item)
else doImport(item.id)
}
function importAll() {
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
if (!ready.length) {
toast('No files ready to import', 'warning')
return
}
ready.forEach((i) => doImport(i.id))
toast(`${ready.length} candidates imported`, 'success')
}
const importedCount = queue.filter((i) => i.imported).length
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Parser · Ready</span>
</div>
</div>
<div className="grid g-2-1">
<div>
<div className="card mb-18">
<div className="card-body">
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => simulate(int(2, 4))}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
simulate(e.dataTransfer.files.length || int(2, 4))
}}
>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
<span className="badge b-gray badge-plain" key={t}>{t}</span>
))}
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
<Icon name="sparkles" /> Simulate 3 files
</button>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
<Icon name="layers" /> Simulate ZIP (8 CVs)
</button>
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
Files are processed locally in this demo
</span>
</div>
</div>
</div>
{queue.length > 0 && (
<div className="card">
<div className="card-head">
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={importAll}>
<Icon name="check" /> Import All
</button>
</div>
<div className="card-body">
{queue.map((i) => (
<div className="upload-row" key={i.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
DUPLICATE
</span>
)}
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Uploading' || i.status === 'Parsing' ? (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
</div>
) : (
<div className="cell-sub" style={{ marginTop: 4 }}>
Best match: <b>{i.job?.title}</b>
</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' ? (
<ScoreChip score={i.atsScore} />
) : (
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
</Badge>
)}
</div>
<div style={{ flexShrink: 0 }}>
{i.imported ? (
<Badge className="b-green">Imported</Badge>
) : i.status === 'Ready' ? (
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
) : (
<button className="act-btn" disabled><Icon name="clock" /></button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"><div><h3>Auto-Processing</h3><span className="ch-sub">What happens on upload</span></div></div>
<div className="card-body">
<div className="timeline">
{STEPS.map((s) => (
<div className="tl-item" key={s.t}>
<div className="tl-dot"><Icon name={s.i} /></div>
<div className="tl-title">{s.t}</div>
<div className="tl-desc">{s.d}</div>
</div>
))}
</div>
</div>
</div>
</div>
{duplicateFor && (
<Modal
title="Duplicate Detected"
subtitle={duplicateFor.name}
onClose={() => setDuplicateFor(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
<button
className="btn btn-secondary"
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
>
Merge
</button>
<button
className="btn btn-primary"
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
>
Import Anyway
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="users" />
</span>
<div>
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
<p className="text-muted" style={{ marginTop: 4 }}>
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
Importing will create a duplicate.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -0,0 +1,257 @@
import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import { Avatar, Icon, KpiCard, ScoreChip } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { analytics, fmtShort, kpis, money, relTime } from '../data/seed'
export default function Dashboard() {
const navigate = useNavigate()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: activity = [] } = useQuery(seedQuery('activity'))
const k = kpis
// Chart payloads must be referentially stable, or <Chart/> re-runs its effect
// and re-animates on every parent render.
const trendData = useMemo(
() => ({
labels: analytics.hiringTrend.labels,
area: true,
datasets: [
{ label: 'Applications', data: analytics.hiringTrend.applications, color: Charts.PALETTE[4] },
{ label: 'Hires', data: analytics.hiringTrend.hires, color: Charts.PALETTE[0] },
],
}),
[],
)
const pipelineData = useMemo(
() => ({
labels: analytics.pipeline.map((p) => p.stage),
data: analytics.pipeline.map((p) => p.count),
colors: Charts.PALETTE,
}),
[],
)
const sourceData = useMemo(
() => ({
labels: analytics.sources.map((s) => s.source),
data: analytics.sources.map((s) => s.count),
}),
[],
)
const legend = useMemo(
() => [
{ label: 'Applications', color: Charts.PALETTE[4] },
{ label: 'Hires', color: Charts.PALETTE[0] },
],
[],
)
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 5)
const recentApps = [...candidates].sort((a, b) => b.applied - a.applied).slice(0, 5)
const topRecruiters = [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5)
const row1 = [
{ label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', tone: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' },
{ label: 'Total Candidates', value: k.totalCandidates, icon: 'users', tone: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' },
{ label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', tone: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' },
{ label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', tone: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` },
]
const row2 = [
{ label: 'Time to Hire', value: `${k.timeToHire} days`, icon: 'clock', tone: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' },
{ label: 'Time to Fill', value: `${k.timeToFill} days`, icon: 'target', tone: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' },
{ label: 'Cost per Hire', value: money(k.costPerHire), icon: 'dollar', tone: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' },
{ label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', tone: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' },
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Good morning, Asfand 👋</h1>
<p className="page-sub">
Heres whats happening with your hiring today Thursday, July 9, 2026
</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>
</div>
</div>
<div className="grid g-kpi">
{row1.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-kpi mt-18">
{row2.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-2-1 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Hiring Trend</h3>
<span className="ch-sub">Hires vs applications over the last 7 months</span>
</div>
<div className="pill-tabs">
<span className="pill-tab active">7M</span>
<span className="pill-tab">1Y</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="line" data={trendData} height={280} />
</div>
<ChartLegend items={legend} />
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Candidate Pipeline</h3>
<span className="ch-sub">Active by stage</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="horizontalBar" data={pipelineData} height={280} />
</div>
</div>
</div>
</div>
<div className="grid g-2-1 mt-18">
<div className="card">
<div className="card-head">
<div>
<h3>Upcoming Interviews</h3>
<span className="ch-sub">Next scheduled sessions</span>
</div>
<Link className="btn btn-ghost btn-sm" to="/interviews">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{upcoming.length === 0 ? (
<div className="empty-state" style={{ padding: 30 }}>No upcoming interviews</div>
) : (
upcoming.map((iv) => (
<div
key={iv.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/interviews')}
>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.jobTitle}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
<div className="lr-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Source Analytics</h3>
<span className="ch-sub">Where candidates come from</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="bar" data={sourceData} height={240} />
</div>
</div>
</div>
</div>
<div className="grid g-3 mt-18">
<div className="card">
<div className="card-head">
<div><h3>Recent Applications</h3></div>
<Link className="btn btn-ghost btn-sm" to="/candidates">View all</Link>
</div>
<div className="card-body">
<div className="list-tight">
{recentApps.map((c) => (
<div
key={c.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div className="lr-main">
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.jobTitle}</div>
</div>
<div className="lr-right"><ScoreChip score={c.aiScore} /></div>
</div>
))}
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recruiter Performance</h3></div></div>
<div className="card-body">
<div className="list-tight">
{topRecruiters.map((r) => (
<div key={r.id} className="list-row">
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub">{r.openReqs} open reqs · {r.avgTimeToHire}d avg</div>
</div>
<div className="lr-right">
<div className="fw-600">{r.hires}</div>
<div className="lr-sub">hires</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="card">
<div className="card-head"><div><h3>Recent Activity</h3></div></div>
<div className="card-body" style={{ maxHeight: 360, overflowY: 'auto' }}>
<div className="list-tight">
{activity.slice(0, 8).map((a, i) => (
<div className="list-row" key={`${a.candidateId}-${i}`}>
<span className={`kpi-icn ${a.color}`} style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>
{a.parts.map((p, j) => (typeof p === 'string' ? p : <b key={j}>{p.b}</b>))}
</div>
<div className="lr-sub">{relTime(a.time)}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,89 @@
import { useState } from 'react'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
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 (0100) 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 candidates 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' },
]
export default function Help() {
const { toast } = useToast()
const [open, setOpen] = useState(null)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Help Center</h1>
<p className="page-sub">Find answers and get support</p>
</div>
</div>
<div className="card brand-hero mb-18">
<div className="card-body" style={{ padding: 32, textAlign: 'center' }}>
<h2 style={{ fontSize: 22, marginBottom: 8 }}>How can we help you?</h2>
<p style={{ opacity: 0.85, marginBottom: 18 }}>
Search our knowledge base or browse the topics below
</p>
<div className="topbar-search" style={{ maxWidth: 480, margin: '0 auto' }}>
<Icon name="search" />
<input placeholder="Search help articles…" />
</div>
</div>
</div>
<div className="grid g-kpi mb-18">
{RESOURCES.map((r) => (
<div key={r.t} className="card" style={{ cursor: 'pointer' }} onClick={() => toast(`Opening ${r.t}`, 'info')}>
<div className="card-body" style={{ textAlign: 'center' }}>
<span className={`kpi-icn ${r.cls}`} style={{ margin: '0 auto 12px', width: 48, height: 48, borderRadius: 14 }}>
<Icon name={r.icn} />
</span>
<div className="fw-600">{r.t}</div>
<div className="lr-sub" style={{ marginTop: 4 }}>{r.d}</div>
</div>
</div>
))}
</div>
<div className="card">
<div className="card-head"><div><h3>Frequently Asked Questions</h3></div></div>
<div className="card-body">
{FAQS.map((f, i) => (
<div
key={f.q}
className="setting-row"
style={{ cursor: 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
onClick={() => setOpen(open === i ? null : i)}
>
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<h4>{f.q}</h4>
<span
style={{
color: 'var(--text-3)',
transition: '.2s',
transform: open === i ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
<Icon name="chevron-right" />
</span>
</div>
{open === i && <p style={{ marginTop: 10 }}>{f.a}</p>}
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,582 @@
/* ============================================================
Recruitment Inbox six seed-backed tabs plus the Email tab, which is the
app's oldest real network call (GET /inbox/fetch, previously the only fetch
in the entire prototype).
The email body used to be interpolated raw into markup at js/inbox.js:292
the single widest XSS sink in the repository, and the one that mattered most
because inbound mail is attacker-supplied by definition. It renders as text
now, which is the structural fix.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import {
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY,
} from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
const NOW = new Date('2026-07-09T20:00')
function resumeText(i) {
return `${i.name.toUpperCase()}
${i.email} · ${i.phone}
${'—'.repeat(30)}
PROFESSIONAL SUMMARY
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
EXPERIENCE
${pick(companies)} Senior role (2021Present)
${pick(companies)} Associate (20182021)
EDUCATION
Bachelor's Degree, Computer Science
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
}
function SourceChip({ item }) {
// The dot carries the partner's brand colour; the label uses theme text
// 11px labels in the partner colour failed AA in both themes.
return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
<span className="source-dot" />
{item.source}
</span>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const updateCandidates = useSeedMutation('candidates')
const [tab, setTab] = useState('All Applications')
const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('')
const [previewing, setPreviewing] = useState(null)
const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null)
const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
const res = await inboxApi.listMessages()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({
id: String(row.id),
from: row.sender_name || row.fromEmail || 'Unknown',
fromEmail: row.fromEmail || '',
subject: row.subject || '',
body: row.body || '',
when: row.when ? new Date(row.when) : new Date(),
unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—',
atsScore: 70,
imported: false,
}))
},
enabled: tab === 'Email',
})
const counts = useMemo(
() => ({
'All Applications': inbox.length,
Unread: inbox.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
Duplicates: inbox.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
[inbox, emailsQuery.data],
)
const list = useMemo(() => {
let l = inbox
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l
}, [inbox, tab, q])
const selected = inbox.find((i) => i.id === selectedId)
function select(id) {
setSelectedId(id)
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i)))
}
function makeCandidate(item, job, cs) {
return {
id: `CAN-${5001 + cs.length}`,
name: item.name, initials: item.initials, color: item.color,
email: item.email, phone: item.phone,
jobId: job.id, jobTitle: job.title, department: job.department,
experience: item.experience, currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: item.source, recruiter: item.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
subScores: { skills: item.atsScore, experience: item.atsScore, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
}
}
function importItem(item) {
const job = getJob(item.jobId) || jobs[0]
updateCandidates((cs) => [makeCandidate(item, job, cs), ...cs])
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Imported', unread: false } : i)))
toast(`${item.name} imported → Applied stage of ${job.title}`, 'success')
}
function parseResume(item) {
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsing' } : i)))
toast('Parsing resume with AI…', 'info')
setTimeout(() => {
updateInbox((items) =>
items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsed', atsScore: int(60, 96) } : i)),
)
toast('Resume parsed — profile fields extracted', 'success')
}, 1100)
}
function moveToPipeline(item) {
if (item.processing !== 'Imported' && item.processing !== 'Processed') importItem(item)
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Processed' } : i)))
toast(`${item.name} moved to pipeline`, 'success')
setTimeout(() => navigate('/pipeline'), 700)
}
function reject(item) {
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Rejected', unread: false } : i)))
toast(`${item.name} rejected`, 'warning')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruitment Inbox</h1>
<p className="page-sub">Every candidate, every source one unified queue</p>
</div>
<div className="page-head-actions">
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
<button
className="btn btn-secondary"
onClick={() => {
toast('Syncing all sources…', 'info')
setTimeout(() => toast('Inbox synced', 'success'), 900)
}}
>
<Icon name="refresh" /> Sync
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
</div>
</div>
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
{tab === 'Email' ? (
<EmailTab query={emailsQuery} jobs={jobs} updateCandidates={updateCandidates} toast={toast} />
) : (
<div className="split">
<div className="split-list">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" />
</div>
</div>
<div>
{list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
) : (
list.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">
{i.name}{' '}
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
</div>
</div>
))
)}
</div>
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
Choose an item from the list to view details and take action.
</EmptyState>
</div>
) : (
<ApplicationDetail
item={selected}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onParse={() => parseResume(selected)}
onAssign={() => setAssigning(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
/>
)}
</div>
</div>
)}
</div>
{previewing && (
<Modal
title={previewing.attachment}
subtitle={`Resume preview · ${previewing.name}`}
size="modal-lg"
onClose={() => setPreviewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setPreviewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
>
<Icon name="user-plus" /> Import Candidate
</button>
</>
}
>
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre>
</Modal>
)}
{assigning && (
<AssignRecruiter
item={assigning}
recruiters={recruiters}
onClose={() => setAssigning(null)}
onSave={(name) => {
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
setAssigning(null)
toast(`Recruiter assigned to ${assigning.name}`, 'success')
}}
/>
)}
{noting && (
<Modal
title="Add Note"
subtitle={noting.name}
onClose={() => setNoting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setNoting(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => { setNoting(null); toast('Note added', 'success') }}>
<Icon name="check" /> Save Note
</button>
</>
}
>
<div className="form-field">
<label>Note</label>
<textarea placeholder="Add a note about this application…" />
</div>
</Modal>
)}
</div>
)
}
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
</div>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div>
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">{resumeText(i)}</pre>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
<Icon name="x" /> Reject
</button>
</div>
</div>
)
}
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
const [name, setName] = useState(item.recruiter)
const current = recruiters.find((r) => r.name === item.recruiter)
return (
<Modal
title="Assign Recruiter"
subtitle={item.name}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal>
)
}
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, jobs, updateCandidates, toast }) {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [imported, setImported] = useState(() => new Set())
const emails = query.data ?? []
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
async function sync() {
toast('Fetching from Outlook…', 'info')
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
if (query.isError) toast('Sync failed', 'error')
else toast('Mailbox synced', 'success')
return res
}
function importEmail(e) {
const job = jobs[0]
if (!job) return
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: e.from, initials: initialsOf(e.from), color: avatarColor(e.from),
email: e.fromEmail, phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), 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',
},
...cs,
])
setImported((s) => new Set(s).add(e.id))
toast(`${e.from} imported from Outlook → ${job.title}`, 'success')
}
const isImported = (e) => imported.has(e.id)
return (
<>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
<span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}>
<Icon name="refresh" /> Sync Mailbox
</button>
</div>
<div className="split">
<div className="split-list">
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => setSelectedId(e.id)}
>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>
<div className="ii-pos">{e.subject}</div>
<div className="ii-meta">
<span className="source-chip" style={{ '--chip': '#0078d4' }}>
<Icon name="mail" />Outlook
</span>
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{fmtShort(e.when)}</div>
</div>
))}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="mail" title="Select an email">
Preview email body and resume attachments here.
</EmptyState>
</div>
) : (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-12" style={{ marginBottom: 6 }}>
<h2 style={{ fontSize: 18, flex: 1 }}>{selected.subject}</h2>
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
</div>
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
<Avatar name={selected.from} />
<div>
<div className="fw-600">{selected.from}</div>
<div className="cell-sub">{selected.fromEmail} · {fmtDate(selected.when)}</div>
</div>
</div>
{/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
in the prototype, and inbound mail is attacker-supplied. */}
<div className="email-preview" style={{ marginBottom: 18, whiteSpace: 'pre-wrap' }}>
{selected.body}
</div>
<div className="attach-card" style={{ marginBottom: 18 }}>
<span className="attach-icn"><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{selected.attachment}</div>
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
<div className="flex items-center gap-8">
<ScoreChip score={selected.atsScore} />
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
<Icon name="eye" /> Preview
</button>
</div>
</div>
<div className="flex gap-8">
{isImported(selected) ? (
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
) : (
<button className="btn btn-primary" onClick={() => importEmail(selected)}>
<Icon name="user-plus" /> Import Candidate
</button>
)}
<button className="btn btn-secondary" onClick={() => toast('Reply drafted', 'info')}>
<Icon name="mail" /> Reply
</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={() => toast('Email archived', 'info')}>
<Icon name="trash" /> Archive
</button>
</div>
</div>
)}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,383 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, AvatarStack, Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import {
candidates as allCandidates, evalTemplates, fmtShort, interviewTypes, meetingTypes,
} from '../data/seed'
export default function Interviews() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [feedbackFor, setFeedbackFor] = useState(null)
const [scheduling, setScheduling] = useState(false)
useEffect(() => {
if (location.state?.openSchedule) setScheduling(true)
}, [location.state])
const stats = useMemo(
() => ({
scheduled: interviews.filter((i) => i.status === 'Scheduled').length,
completed: interviews.filter((i) => i.status === 'Completed').length,
today: 5,
cancelled: interviews.filter((i) => ['Cancelled', 'No Show'].includes(i.status)).length,
}),
[interviews],
)
const rows = useMemo(
() =>
interviews.filter((iv) => {
if (status && iv.status !== status) return false
if (type && iv.type !== type) return false
if (q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[interviews, q, status, type],
)
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 4)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (iv) => (
<div className="user-cell">
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div>
<div className="cell-primary">{iv.candidate}</div>
<div className="cell-sub">{iv.jobTitle}</div>
</div>
</div>
),
},
{ key: 'type', label: 'Round', sortable: true, render: (iv) => <Badge className="b-indigo">{iv.type}</Badge> },
{
key: 'when', label: 'Date & Time', sortable: true, sortValue: (iv) => iv.when.getTime(),
render: (iv) => (
<>
<div className="text-sm fw-600">{fmtShort(iv.when)}</div>
<div className="cell-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · {iv.duration}m
</div>
</>
),
},
{
key: 'meeting', label: 'Type',
render: (iv) => (
<span className="flex items-center gap-8">
<Icon name={iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map'} />
{iv.meeting}
</span>
),
},
{ key: 'interviewers', label: 'Interviewers', render: (iv) => <AvatarStack names={iv.interviewers} /> },
{ key: 'status', label: 'Status', sortable: true, render: (iv) => <Badge>{iv.status}</Badge> },
{ key: 'feedback', label: 'Feedback', render: (iv) => (iv.feedback ? <Badge>{iv.feedback}</Badge> : <span className="text-muted"></span>) },
{
key: '_a', label: 'Actions', align: 'right',
render: (iv) => (
<div className="row-actions">
<button
className="act-btn" data-tip="View candidate"
onClick={() => navigate('/candidates', { state: { openCandidate: iv.candidateId } })}
>
<Icon name="eye" />
</button>
<button className="act-btn" data-tip="Feedback" onClick={() => setFeedbackFor(iv)}>
<Icon name="star" />
</button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Interviews</h1>
<p className="page-sub">Manage and track all interview activity</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/calendar"><Icon name="calendar" /> Calendar View</Link>
<button className="btn btn-primary" onClick={() => setScheduling(true)}>
<Icon name="plus" /> Schedule Interview
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Scheduled" value={stats.scheduled} icon="calendar" tone="i-blue" />
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
<KpiCard label="Today" value={stats.today} icon="clock" tone="i-purple" />
<KpiCard label="Cancelled / No-show" value={stats.cancelled} icon="x-circle" tone="i-red" />
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-head"><div><h3>All Interviews</h3></div></div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or interviewer…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Scheduled', 'Completed', 'Cancelled', 'No Show'].map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Rounds</option>
{interviewTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head"><div><h3>Up Next</h3><span className="ch-sub">Scheduled sessions</span></div></div>
<div className="card-body">
<div className="list-tight">
{upcoming.map((iv) => (
<div className="list-row" key={iv.id}>
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.meeting}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
<div className="lr-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{feedbackFor && (
<Scorecard
interview={feedbackFor}
jobs={jobs}
onClose={() => setFeedbackFor(null)}
onSubmit={() => { setFeedbackFor(null); toast('Scorecard submitted', 'success') }}
toast={toast}
/>
)}
{scheduling && (
<ScheduleForm
people={[...recruiters, ...managers]}
onClose={() => setScheduling(false)}
onSubmit={() => { setScheduling(false); toast('Interview scheduled & invite sent', 'success') }}
/>
)}
</div>
)
}
/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */
function Stars({ value, onChange }) {
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => onChange(n)}
role="radio"
aria-checked={n === value}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
<div className="setting-row" style={{ padding: '12px 0' }} key={c}>
<div className="setting-info"><h4>{c}</h4></div>
<Stars value={ratings[c] ?? 0} onChange={(v) => setRating(c, v)} />
</div>
))
}
function Scorecard({ interview: iv, jobs, onClose, onSubmit, toast }) {
const job = jobs.find((j) => j.title === iv.jobTitle)
const dept = job ? job.department : 'All'
const initial = evalTemplates.find((t) => t.dept === dept) || evalTemplates.find((t) => t.dept === 'All')
const [tab, setTab] = useState('form')
const [templateName, setTemplateName] = useState(initial.name)
const [ratings, setRatings] = useState({})
const [recommendation, setRecommendation] = useState('Hire')
const template = evalTemplates.find((t) => t.name === templateName) ?? initial
const setRating = (crit, val) => setRatings((r) => ({ ...r, [crit]: val }))
return (
<Modal
title="Interview Evaluation"
subtitle={`${iv.id} · ${iv.type}`}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={onSubmit}><Icon name="check" /> Submit Scorecard</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
<div className="ph-role">{iv.type} · {iv.jobTitle}</div>
</div>
<Badge>{iv.status}</Badge>
</div>
<div style={{ marginBottom: 18 }}>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'form', label: 'Dynamic Form' },
{ key: 'upload', label: 'Upload Sheet' },
{ key: 'both', label: 'Both' },
]}
/>
</div>
{tab === 'form' && (
<div className="tab-pane active">
<div className="form-field" style={{ marginBottom: 8 }}>
<label>Evaluation Template</label>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
{evalTemplates.map((t) => <option key={t.name}>{t.name}</option>)}
</select>
</div>
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
<div className="form-field" style={{ marginTop: 8 }}>
<label>Comments</label>
<textarea placeholder="Strengths, concerns, and areas explored…" />
</div>
<div className="form-field" style={{ marginTop: 14 }}>
<label>Overall Recommendation</label>
<div className="seg" style={{ marginTop: 4 }}>
{['Hire', 'Hold', 'Reject'].map((r) => (
<button
type="button" key={r}
className={r === recommendation ? 'active' : ''}
onClick={() => setRecommendation(r)}
>
{r}
</button>
))}
</div>
</div>
</div>
)}
{tab === 'upload' && (
<div className="tab-pane active">
<div className="dropzone" style={{ padding: 32 }} onClick={() => toast('File picker (demo)', 'info')}>
<div className="dz-icn"><Icon name="upload" /></div>
<h3 style={{ fontSize: 15 }}>Upload evaluation sheet</h3>
<p className="text-muted">PDF, DOC, or DOCX · scanned scorecards supported</p>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 12 }}>
{['PDF', 'DOC', 'DOCX'].map((t) => <span className="badge b-gray badge-plain" key={t}>{t}</span>)}
</div>
</div>
</div>
)}
{tab === 'both' && (
<div className="tab-pane active">
<p className="text-muted" style={{ marginBottom: 14 }}>
Capture structured ratings <b>and</b> attach a signed sheet both are stored on the scorecard.
</p>
<CriteriaList criteria={template.criteria.slice(0, 3)} ratings={ratings} setRating={setRating} />
<div className="upload-row" style={{ marginTop: 12 }}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600 text-sm">Interviewer_Scorecard.pdf</div>
<div className="cell-sub">Attached · 214 KB</div>
</div>
<Badge className="b-green">Uploaded</Badge>
</div>
</div>
)}
</Modal>
)
}
function ScheduleForm({ people, onClose, onSubmit }) {
return (
<Modal
title="Schedule Interview"
subtitle="Set up a new interview session"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={onSubmit}><Icon name="calendar" /> Schedule</button>
</>
}
>
<form>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
</div>
<div className="form-field">
<label>Interview Round</label>
<select>{interviewTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Meeting Type</label>
<select>{meetingTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field"><label>Date</label><input type="date" /></div>
<div className="form-field"><label>Time</label><input type="time" defaultValue="14:00" /></div>
<div className="form-field">
<label>Duration</label>
<select defaultValue="60 min"><option>30 min</option><option>45 min</option><option>60 min</option><option>90 min</option></select>
</div>
<div className="form-field">
<label>Interviewer</label>
<select>{people.map((p) => <option key={p.id}>{p.name}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,344 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { int, publishPlatforms, TODAY } from '../data/seed'
const STEPS = ['Select Job', 'Approval', 'Platforms', 'Publish']
export default function JobBoard() {
const { toast } = useToast()
const location = useLocation()
const { data: publishings = [] } = useQuery(seedQuery('publishings'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const updatePublishings = useSeedMutation('publishings')
const [publishing, setPublishing] = useState(null) // { jobId } | null
useEffect(() => {
if (location.state?.publishJob) setPublishing({ jobId: location.state.publishJob })
}, [location.state])
const totals = useMemo(
() =>
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 },
),
[publishings],
)
const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : '0'
const platRows = useMemo(() => {
const agg = {}
for (const p of publishings) {
if (!agg[p.platform]) agg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 }
agg[p.platform].views += p.views
agg[p.platform].clicks += p.clicks
agg[p.platform].apps += p.apps
agg[p.platform].jobs += 1
}
return Object.entries(agg).sort((a, b) => b[1].apps - a[1].apps)
}, [publishings])
const chartData = useMemo(
() => ({ labels: platRows.map((p) => p[0]), data: platRows.map((p) => p[1].apps) }),
[platRows],
)
const columns = [
{
key: 'jobTitle', label: 'Job', sortable: true,
render: (p) => (<><div className="cell-primary">{p.jobTitle}</div><div className="cell-sub">{p.jobId}</div></>),
},
{
key: 'platform', label: 'Platform', sortable: true,
render: (p) => {
const pl = publishPlatforms.find((x) => x.name === p.platform) || {}
return (
<span className="flex items-center gap-8">
<span className="platform-logo" style={{ width: 26, height: 26, background: pl.color || '#888' }}>
<Icon name={pl.icon || 'briefcase'} />
</span>
{p.platform}
</span>
)
},
},
{
key: 'status', label: 'Status', sortable: true,
render: (p) => (
<Badge className={p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue'}>
{p.status}
</Badge>
),
},
{ 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) => <b>{p.apps}</b> },
{
key: '_conv', label: 'Conversion', sortable: true, sortValue: (p) => (p.views ? p.apps / p.views : 0),
render: (p) => (
<span className="badge b-indigo badge-plain">
{p.views ? ((p.apps / p.views) * 100).toFixed(1) : '0.0'}%
</span>
),
},
{
key: '_a', label: '', align: 'right',
render: (p) => (
<button className="act-btn" data-tip="Manage" onClick={() => toast(`Managing ${p.platform} posting`, 'info')}>
<Icon name="external" />
</button>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Job Board</h1>
<p className="page-sub">Publish requisitions across channels and track performance</p>
</div>
<div className="page-head-actions">
<Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link>
<button className="btn btn-primary" onClick={() => setPublishing({})}>
<Icon name="send" /> Publish a Job
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Views" value={totals.views.toLocaleString()} icon="eye" tone="i-blue" foot="across all platforms" />
<KpiCard
label="Total Clicks" value={totals.clicks.toLocaleString()} icon="target" tone="i-purple"
foot={`${totals.views ? ((totals.clicks / totals.views) * 100).toFixed(1) : '0.0'}% CTR`}
/>
<KpiCard label="Applications" value={totals.apps.toLocaleString()} icon="users" tone="i-green" foot="from job boards" />
<KpiCard label="Conversion Rate" value={`${conv}%`} icon="trending-up" tone="i-teal" foot="view → application" />
</div>
<div className="grid g-2-1 mb-18">
<div className="card">
<div className="card-head"><div><h3>Platform Performance</h3><span className="ch-sub">Applications by channel</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={chartData} height={300} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Connected Platforms</h3></div></div>
<div className="card-body">
<div className="list-tight">
{publishPlatforms.map((p) => (
<div className="list-row" key={p.name}>
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
<div className="lr-main">
<div className="lr-title">{p.name}</div>
<div className="lr-sub">{p.cost === 'Free' ? 'Free posting' : `Paid · ${p.cost}`}</div>
</div>
{p.connected ? (
<Badge className="b-green">Connected</Badge>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => toast(`Connecting ${p.name}`, 'info')}>
Connect
</button>
)}
</div>
))}
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Active Postings</h3>
<span className="ch-sub">{publishings.length} live postings across {platRows.length} platforms</span>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => toast('Performance report exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
<DataTable columns={columns} rows={publishings} pageSize={8} />
</div>
{publishing && (
<PublishFlow
jobs={jobs}
initialJobId={publishing.jobId}
onClose={() => setPublishing(null)}
onPublish={(job, platforms) => {
updatePublishings((ps) => [
...platforms.map((p) => ({
jobId: job.id, jobTitle: job.title, platform: p, status: 'Live',
views: int(0, 30), clicks: 0, apps: 0, published: new Date(TODAY),
})),
...ps,
])
}}
toast={toast}
/>
)}
</div>
)
}
/** The app's only multi-step form. State lives here rather than on a global. */
function PublishFlow({ jobs, initialJobId, onClose, onPublish, toast }) {
const publishable = jobs.filter((j) => j.status !== 'Draft')
const openJobs = jobs.filter((j) => j.status === 'Open')
const [step, setStep] = useState(1)
const [jobId, setJobId] = useState(initialJobId || openJobs[0]?.id || publishable[0]?.id)
const [platforms, setPlatforms] = useState(['Career Portal'])
const job = jobs.find((j) => j.id === jobId)
function next() {
if (step === 3) {
if (!platforms.length) {
toast('Select at least one platform', 'warning')
return
}
onPublish(job, platforms)
}
setStep((s) => s + 1)
}
function togglePlatform(name) {
setPlatforms((ps) => (ps.includes(name) ? ps.filter((p) => p !== name) : [...ps, name]))
}
return (
<Modal
title="Publish Job"
subtitle="Distribute this requisition to job boards"
size="modal-lg"
onClose={onClose}
footer={
step === 4 ? (
<button className="btn btn-primary" onClick={onClose}><Icon name="check" /> Done</button>
) : (
<>
<button className="btn btn-secondary" onClick={() => (step === 1 ? onClose() : setStep((s) => s - 1))}>
{step === 1 ? 'Cancel' : 'Back'}
</button>
<button className="btn btn-primary" onClick={next}>
{step === 3 ? <><Icon name="send" /> Publish</> : 'Continue'}
</button>
</>
)
}
>
<div className="stepper">
{STEPS.map((s, i) => {
const n = i + 1
const cls = n < step ? 'done' : n === step ? 'active' : ''
return (
<div style={{ display: 'contents' }} key={s}>
<div className={`step ${cls}`}>
<div className="step-num">{n < step ? '✓' : n}</div>
<div className="step-label">{s}</div>
</div>
{i < STEPS.length - 1 && <div className={`step-line ${n < step ? 'done' : ''}`} />}
</div>
)
})}
</div>
{step === 1 && (
<>
<div className="form-field">
<label>Select requisition to publish</label>
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
{publishable.map((j) => <option key={j.id} value={j.id}>{j.title} · {j.id}</option>)}
</select>
</div>
{job && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginTop: 16 }}>
<div className="card-body">
<div className="flex items-center gap-12">
<span className="kpi-icn i-indigo" style={{ width: 44, height: 44, borderRadius: 12 }}>
<Icon name="briefcase" />
</span>
<div>
<div className="fw-600">{job.title}</div>
<div className="cell-sub">{job.department} · {job.location} · {job.type}</div>
</div>
</div>
</div>
</div>
)}
</>
)}
{step === 2 && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<span className="kpi-icn i-green" style={{ width: 44, height: 44, borderRadius: 12 }}>
<Icon name="check-circle" />
</span>
<div>
<div className="fw-600">Approval granted</div>
<div className="cell-sub">Approved by Department Head · Budget confirmed</div>
</div>
</div>
{['Hiring Manager sign-off', 'Finance budget approval', 'Compliance review'].map((label, i) => (
<div className="setting-row" style={{ padding: '10px 0', ...(i === 2 ? { border: 'none' } : {}) }} key={label}>
<div className="setting-info"><h4>{label}</h4></div>
<Badge className="b-green">Approved</Badge>
</div>
))}
</div>
</div>
)}
{step === 3 && (
<>
<p className="text-muted" style={{ marginBottom: 14 }}>
Select the platforms to publish this role to
</p>
<div className="grid g-2">
{publishPlatforms.map((p) => (
<div
key={p.name}
className={`platform-card${platforms.includes(p.name) ? ' selected' : ''}${!p.connected ? ' disabled' : ''}`}
style={!p.connected ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
onClick={() => togglePlatform(p.name)}
>
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{p.name}</div>
<div className="cell-sub">{p.cost === 'Free' ? 'Free' : `Paid · ${p.cost}`}</div>
</div>
<span className="platform-check"><Icon name="check" /></span>
</div>
))}
</div>
</>
)}
{step === 4 && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div className="kpi-icn i-green" style={{ width: 64, height: 64, borderRadius: 18, margin: '0 auto 16px' }}>
<Icon name="check-circle" />
</div>
<h2 style={{ fontSize: 20, marginBottom: 6 }}>Published Successfully</h2>
<p className="text-muted" style={{ marginBottom: 20 }}>
{job?.title} is now live on {platforms.length} platform{platforms.length > 1 ? 's' : ''}
</p>
<div className="flex gap-8" style={{ justifyContent: 'center', flexWrap: 'wrap' }}>
{platforms.map((p) => <Badge className="b-green" key={p}>{p}</Badge>)}
</div>
</div>
)}
</Modal>
)
}

View File

@ -0,0 +1,520 @@
/* ============================================================
Jobs the reference CRUD pattern for the app: filtered DataTable plus
view / reassign / create-edit / delete modals. The other CRUD screens follow
this shape.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, FieldError, Icon, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
businessUnits, departments, educationLevels, empTypes, fmtDate, fmtShort,
getRecruiterByName, grades, jobStatuses, locations, moneyK, TODAY,
} from '../data/seed'
export default function Jobs() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateJobs = useSeedMutation('jobs')
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [editing, setEditing] = useState(undefined) // undefined = closed, null = create
const [reassigning, setReassigning] = useState(null)
const [deleting, setDeleting] = useState(null)
// Deep-link intents from global search, the dashboard and the manager portal.
useEffect(() => {
const st = location.state
if (!st) return
if (st.openCreate) setEditing(null)
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
}, [location.state, jobs])
const rows = useMemo(
() =>
jobs.filter((j) => {
if (dept && j.department !== dept) return false
if (status && j.status !== status) return false
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = (j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase()
if (!hay.includes(term)) return false
}
return true
}),
[jobs, q, dept, status, type],
)
const openCount = jobs.filter((j) => j.status === 'Open').length
const columns = [
{ key: 'id', label: 'Job ID', sortable: true, render: (j) => <span className="cell-mono">{j.id}</span> },
{
key: 'title', label: 'Job Title', sortable: true,
render: (j) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{j.businessUnit} · {j.grade}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true },
{
key: 'manager', label: 'Hiring Manager', sortable: true,
render: (j) => (
<div className="user-cell"><Avatar name={j.manager} /><span>{j.manager}</span></div>
),
},
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location}</span> },
{ key: 'type', label: 'Type', render: (j) => <Badge className="b-gray">{j.type}</Badge> },
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => <b>{j.applications}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => <span className="text-muted">{fmtShort(j.created)}</span> },
{
key: '_a', label: 'Actions', align: 'right',
render: (j) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
<button className="act-btn danger" data-tip="Delete" onClick={() => setDeleting(j)}><Icon name="trash" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Jobs</h1>
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setEditing(null)}>
<Icon name="plus" /> Create Job
</button>
</div>
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{jobStatuses.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && (
<JobDetail
job={viewing}
onClose={() => setViewing(null)}
onEdit={() => { const j = viewing; setViewing(null); setEditing(j) }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onReassign={() => { const j = viewing; setViewing(null); setReassigning(j) }}
/>
)}
{editing !== undefined && (
<JobForm
job={editing}
managers={managers}
recruiters={recruiters}
count={jobs.length}
onClose={() => setEditing(undefined)}
onSave={(next, isEdit) => {
updateJobs((js) => (isEdit ? js.map((j) => (j.id === next.id ? next : j)) : [next, ...js]))
setEditing(undefined)
toast(isEdit ? 'Job updated successfully' : 'Job created successfully', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
/>
)}
{reassigning && (
<Reassign
job={reassigning}
recruiters={recruiters}
onClose={() => setReassigning(null)}
onSave={(name) => {
updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j)))
setReassigning(null)
toast('Recruiter reassigned', 'success')
}}
/>
)}
{deleting && (
<Modal
title="Confirm Deletion"
onClose={() => setDeleting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDeleting(null)}>Cancel</button>
<button
className="btn btn-danger"
onClick={() => {
updateJobs((js) => js.filter((j) => j.id !== deleting.id))
setDeleting(null)
toast('Job deleted', 'success')
}}
>
<Icon name="trash" /> Delete Job
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-red" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="trash" />
</span>
<div>
<p style={{ fontWeight: 600, fontSize: 15 }}>Delete {deleting.title}?</p>
<p className="text-muted" style={{ marginTop: 4 }}>
This will permanently remove requisition {deleting.id} and its {deleting.applications} applications.
This action cannot be undone.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}
const SECTION_LABEL = {
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
textTransform: 'uppercase', marginBottom: 6,
}
function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) {
const r = getRecruiterByName(j.recruiter)
const loadCls = r ? (r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green') : ''
return (
<Modal
title="Job Details"
subtitle={j.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
<button className="btn btn-primary" onClick={onEdit}><Icon name="edit" /> Edit Job</button>
</>
}
>
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{j.id} · {j.department} · {j.businessUnit}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.manager}</div></div>
<div className="info-item">
<div className="il">Assigned Recruiter</div>
<div className="iv flex items-center gap-8">
{j.recruiter}
{r && <span className={`badge ${loadCls} badge-plain`} style={{ fontSize: 10 }}>{r.workload}% load</span>}
<button className="link-btn" onClick={onReassign}>Reassign</button>
</div>
</div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type}</div></div>
<div className="info-item"><div className="il">Grade</div><div className="iv">{j.grade}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies}</div></div>
<div className="info-item"><div className="il">Salary Range</div><div className="iv">{moneyK(j.salaryMin)} {moneyK(j.salaryMax)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{j.education}</div></div>
<div className="info-item"><div className="il">Deadline</div><div className="iv">{fmtDate(j.deadline)}</div></div>
</div>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Key Responsibilities</div>
<ul style={{ paddingLeft: 18, color: 'var(--text-2)' }}>
{j.responsibilities.map((x) => <li key={x}>{x}</li>)}
</ul>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div>
<div style={SECTION_LABEL}>Benefits</div>
<div className="k-tags">{j.benefits.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div className="divider" />
<div className="flex items-center gap-12">
<span className="text-muted text-sm">Hiring progress</span>
<div style={{ flex: 1 }}><ProgressBar pct={j.progress} /></div>
<span className="fw-600">{j.progress}%</span>
</div>
</Modal>
)
}
function Reassign({ job, recruiters, onClose, onSave }) {
const [name, setName] = useState(job.recruiter)
return (
<Modal
title="Reassign Recruiter"
subtitle={job.title}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Reassign</button>
</>
}
>
<div className="form-field">
<label>Assigned Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => (
<option key={r.id} value={r.name}>{r.name} {r.workload}% load</option>
))}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Workload is recalculated automatically across the recruiters assigned requisitions.
</p>
</Modal>
)
}
function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid }) {
const isEdit = Boolean(job)
const form = useFormState({
title: job?.title ?? '',
department: job?.department ?? departments[0],
businessUnit: job?.businessUnit ?? businessUnits[0],
grade: job?.grade ?? grades[0],
type: job?.type ?? empTypes[0],
manager: job?.manager ?? managers[0]?.name ?? '',
recruiter: job?.recruiter ?? recruiters[0]?.name ?? '',
salaryMin: job?.salaryMin ?? '',
salaryMax: job?.salaryMax ?? '',
experience: job?.experience ?? '',
education: job?.education ?? educationLevels[0],
location: job?.location ?? locations[0],
vacancies: job?.vacancies ?? 1,
description: job?.description ?? '',
responsibilities: job ? job.responsibilities.join('\n') : '',
skills: job ? job.skills.join(', ') : '',
benefits: job ? job.benefits.join(', ') : '',
deadline: '',
status: job?.status ?? 'Open',
})
function submit() {
const v = form.values
const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required'
if (!v.description.trim()) errors.description = 'Description is required'
if (!v.salaryMin || Number(v.salaryMin) <= 0) errors.salaryMin = 'Enter a valid amount'
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
}
const skills = v.skills.split(',').map((s) => s.trim()).filter(Boolean)
const benefits = v.benefits.split(',').map((s) => s.trim()).filter(Boolean)
const responsibilities = v.responsibilities.split('\n').map((s) => s.trim()).filter(Boolean)
const salaryMin = Number(v.salaryMin)
const salaryMax = Number(v.salaryMax) || salaryMin + 20000
if (isEdit) {
onSave(
{
...job,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
type: v.type, manager: v.manager, recruiter: v.recruiter, salaryMin, salaryMax,
experience: v.experience, education: v.education, location: v.location,
vacancies: Number(v.vacancies) || 1, description: v.description, responsibilities,
skills: skills.length ? skills : job.skills,
benefits: benefits.length ? benefits : job.benefits,
status: v.status,
},
true,
)
return
}
onSave(
{
id: `JOB-${1001 + count}`,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
manager: v.manager, managerId: '', recruiter: v.recruiter, recruiterId: '',
location: v.location, type: v.type, vacancies: Number(v.vacancies) || 1,
applications: 0, status: v.status, created: new Date(TODAY),
deadline: v.deadline ? new Date(v.deadline) : new Date('2026-08-09'),
salaryMin, salaryMax,
experience: v.experience || '3+ years', education: v.education,
skills, benefits, description: v.description,
responsibilities: responsibilities.length ? responsibilities : ['Own key projects'],
progress: 0,
},
false,
)
}
const field = (name) => ({
value: form.values[name],
onChange: (e) => form.setField(name, e.target.value),
})
return (
<Modal
title={isEdit ? 'Edit Job' : 'Create New Job'}
subtitle={isEdit ? job.id : 'Fill in the details to post a requisition'}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}>
<Icon name="check" /> {isEdit ? 'Save Changes' : 'Create Job'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Job Title <span className="req">*</span></label>
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Product Designer" />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Department <span className="req">*</span></label>
<select {...field('department')}>{departments.map((d) => <option key={d}>{d}</option>)}</select>
</div>
<div className="form-field">
<label>Business Unit</label>
<select {...field('businessUnit')}>{businessUnits.map((b) => <option key={b}>{b}</option>)}</select>
</div>
<div className="form-field">
<label>Grade</label>
<select {...field('grade')}>{grades.map((g) => <option key={g}>{g}</option>)}</select>
</div>
<div className="form-field">
<label>Employment Type</label>
<select {...field('type')}>{empTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Hiring Manager <span className="req">*</span></label>
<select {...field('manager')}>{managers.map((m) => <option key={m.id}>{m.name}</option>)}</select>
</div>
<div className="form-field">
<label>Recruiter <span className="req">*</span></label>
<select {...field('recruiter')}>{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}</select>
</div>
<div className="form-field">
<label>Salary Min ($) <span className="req">*</span></label>
<input type="number" {...field('salaryMin')} className={form.errors.salaryMin ? 'err' : ''} placeholder="90000" />
<FieldError>{form.errors.salaryMin}</FieldError>
</div>
<div className="form-field">
<label>Salary Max ($)</label>
<input type="number" {...field('salaryMax')} placeholder="130000" />
</div>
<div className="form-field">
<label>Experience</label>
<input {...field('experience')} placeholder="5+ years" />
</div>
<div className="form-field">
<label>Education</label>
<select {...field('education')}>{educationLevels.map((e) => <option key={e}>{e}</option>)}</select>
</div>
<div className="form-field">
<label>Location <span className="req">*</span></label>
<select {...field('location')}>{locations.map((l) => <option key={l}>{l}</option>)}</select>
</div>
<div className="form-field">
<label>Vacancies</label>
<input type="number" min="1" {...field('vacancies')} />
</div>
<div className="form-field col-span-2">
<label>Job Description <span className="req">*</span></label>
<textarea {...field('description')} className={form.errors.description ? 'err' : ''} placeholder="Describe the role…" />
<FieldError>{form.errors.description}</FieldError>
</div>
<div className="form-field col-span-2">
<label>Responsibilities</label>
<textarea {...field('responsibilities')} placeholder="One per line…" />
</div>
<div className="form-field col-span-2">
<label>Required Skills</label>
<input {...field('skills')} placeholder="React, TypeScript, System Design" />
</div>
<div className="form-field col-span-2">
<label>Benefits</label>
<input {...field('benefits')} placeholder="Equity, 401(k), Unlimited PTO" />
</div>
<div className="form-field">
<label>Deadline</label>
<input type="date" {...field('deadline')} />
</div>
<div className="form-field">
<label>Status</label>
<select {...field('status')}>{jobStatuses.map((s) => <option key={s}>{s}</option>)}</select>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Avatar, Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
export default function Managers() {
const { toast } = useToast()
const navigate = useNavigate()
const location = useLocation()
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const [detail, setDetail] = useState(null)
// Global search navigates here with the manager to open replaces the old
// App.searchGo(route, cb) + setTimeout(cb, 120) hack.
useEffect(() => {
const id = location.state?.openManager
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
}, [location.state, managers])
const totalReqs = managers.reduce((s, m) => s + m.openReqs, 0)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Hiring Managers</h1>
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Invite manager', 'info')}>
<Icon name="plus" /> Add Manager
</button>
</div>
</div>
<div className="grid g-3">
{managers.map((m) => (
<div className="card" key={m.id}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title}</div>
</div>
</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize}</span><span className="stat-mini-lbl">Team Size</span></div>
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="mail" /> {m.email.split('@')[0]}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div>
</div>
</div>
))}
</div>
{detail && (
<ManagerDetail
manager={detail}
jobs={jobs.filter((j) => j.manager === detail.name)}
onClose={() => setDetail(null)}
navigate={navigate}
toast={toast}
/>
)}
</div>
)
}
function ManagerDetail({ manager: m, jobs, onClose, navigate, toast }) {
const go = (path, state) => {
onClose()
navigate(path, { state })
}
return (
<Modal
title="Hiring Manager"
subtitle={m.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-primary" onClick={() => toast('Message sent', 'success')}>
<Icon name="mail" /> Message
</button>
</>
}
>
<div className="profile-hero" style={{ marginBottom: 18 }}>
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
<div>
<div className="ph-name">{m.name}</div>
<div className="ph-role">{m.title}</div>
<div className="ph-tags">
<Badge className="b-indigo">{m.department}</Badge>
<span className="badge b-gray badge-plain">{m.teamSize} reports</span>
</div>
</div>
</div>
<div className="grid g-3" style={{ marginBottom: 18 }}>
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Total Jobs</span></div>
<div className="stat-mini">
<span className="stat-mini-val">{jobs.reduce((s, j) => s + j.applications, 0)}</span>
<span className="stat-mini-lbl">Applications</span>
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
<Icon name="plus" /> Raise Requisition
</button>
<button className="btn btn-secondary" onClick={() => go('/candidates')}>
<Icon name="users" /> Review Candidates
</button>
<button className="btn btn-secondary" onClick={() => go('/interviews', { openSchedule: true })}>
<Icon name="calendar" /> Schedule Interview
</button>
<button className="btn btn-secondary" onClick={() => go('/offers')}>
<Icon name="check-circle" /> Approve Offers
</button>
</div>
<div className="form-section-title">Requisitions</div>
<div className="list-tight">
{jobs.length === 0 ? (
<p className="text-muted">No requisitions</p>
) : (
jobs.map((j) => (
<div
key={j.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => go('/jobs', { openJob: j.id })}
>
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name="briefcase" />
</span>
<div className="lr-main">
<div className="lr-title">{j.title}</div>
<div className="lr-sub">{j.applications} applications</div>
</div>
<Badge>{j.status}</Badge>
</div>
))
)}
</div>
</Modal>
)
}

View File

@ -0,0 +1,57 @@
import { useQuery } from '@tanstack/react-query'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
export default function Notifications() {
const { toast } = useToast()
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const update = useSeedMutation('notifications')
// Marking one read used to be `this.classList.remove('unread')` a DOM edit
// the badge count never saw. Writing to the cache keeps the sidebar in sync.
const markOne = (i) => update((ns) => ns.map((n, j) => (j === i ? { ...n, unread: false } : n)))
const markAll = () => {
update((ns) => ns.map((n) => ({ ...n, unread: false })))
toast('All notifications marked as read', 'success')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Notifications</h1>
<p className="page-sub">Stay on top of hiring activity</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={markAll}><Icon name="check" /> Mark all read</button>
<button className="btn btn-ghost" onClick={() => toast('Notification settings', 'info')}>
<Icon name="more" />
</button>
</div>
</div>
<div className="card">
<div className="list-tight" style={{ padding: 0 }}>
{notifications.map((n, i) => (
<div
key={n.id ?? `${n.title}-${i}`}
className={`notif-row${n.unread ? ' unread' : ''}`}
onClick={() => markOne(i)}
>
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
<div className="notif-body">
<div className="notif-title">{n.title}</div>
<div className="notif-text">{n.text}</div>
<div className="notif-time">{n.time}</div>
</div>
{n.unread && (
<span className="dot dot-blue" style={{ position: 'static', border: 'none', alignSelf: 'center' }} />
)}
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,259 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, FieldError, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { useFormState } from '../components/AuthLayout'
import { fmtDate, fmtShort, money, TODAY } from '../data/seed'
export default function Offers() {
const { toast } = useToast()
const { data: offers = [] } = useQuery(seedQuery('offers'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateOffers = useSeedMutation('offers')
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [viewing, setViewing] = useState(null)
const [creating, setCreating] = useState(false)
const stats = useMemo(() => {
const decided = offers.filter((o) => ['Accepted', 'Declined'].includes(o.status)).length
return {
sent: offers.filter((o) => o.status !== 'Draft').length,
accepted: offers.filter((o) => o.status === 'Accepted').length,
pending: offers.filter((o) => ['Sent', 'Negotiating'].includes(o.status)).length,
rate: Math.round((offers.filter((o) => o.status === 'Accepted').length / (decided || 1)) * 100),
}
}, [offers])
const rows = useMemo(
() =>
offers.filter((o) => {
if (status && o.status !== status) return false
if (q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[offers, q, status],
)
const columns = [
{
key: 'candidate', label: 'Candidate', sortable: true,
render: (o) => (
<div className="user-cell">
<Avatar name={o.candidate} initials={o.initials} color={o.color} />
<div>
<div className="cell-primary">{o.candidate}</div>
<div className="cell-sub">{o.jobTitle}</div>
</div>
</div>
),
},
{ key: 'department', label: 'Department', sortable: true },
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: (o) => <b>{money(o.base)}</b> },
{ key: 'equity', label: 'Equity', render: (o) => <span className="text-muted">{o.equity}</span> },
{ key: 'bonus', label: 'Bonus', align: 'center', render: (o) => <span className="text-muted">{o.bonus}</span> },
{ key: 'sent', label: 'Sent', sortable: true, sortValue: (o) => o.sent.getTime(), render: (o) => <span className="text-muted">{fmtShort(o.sent)}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (o) => <Badge>{o.status}</Badge> },
{
key: '_a', label: 'Actions', align: 'right',
render: (o) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Resend" onClick={() => toast(`Offer resent to ${o.candidate}`, 'info')}><Icon name="send" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Offers</h1>
<p className="page-sub">Track offer letters and acceptance</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Offer
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Offers Sent" value={stats.sent} icon="send" tone="i-indigo" />
<KpiCard label="Accepted" value={stats.accepted} icon="check-circle" tone="i-green" />
<KpiCard label="Awaiting Response" value={stats.pending} icon="clock" tone="i-amber" />
<KpiCard label="Acceptance Rate" value={`${stats.rate}%`} icon="trending-up" tone="i-teal" />
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
</div>
{viewing && <OfferDetail offer={viewing} onClose={() => setViewing(null)} toast={toast} />}
{creating && (
<CreateOffer
candidates={candidates}
onClose={() => setCreating(false)}
onSave={(offer) => {
updateOffers((os) => [offer, ...os])
setCreating(false)
toast('Offer sent successfully', 'success')
}}
toast={toast}
/>
)}
</div>
)
}
function OfferDetail({ offer: o, onClose, toast }) {
const total = o.base + Math.round((o.base * parseInt(o.bonus, 10)) / 100)
return (
<Modal
title="Offer Details"
subtitle={o.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={() => toast('Offer PDF downloaded', 'info')}>
<Icon name="download" /> Download
</button>
<button className="btn btn-primary" onClick={() => { onClose(); toast('Offer resent', 'success') }}>
<Icon name="send" /> Resend Offer
</button>
</>
}
>
<div className="flex items-center gap-12 mb-18">
<Avatar name={o.candidate} initials={o.initials} color={o.color} className="avatar-lg" />
<div>
<div className="ph-name" style={{ fontSize: 17 }}>{o.candidate}</div>
<div className="ph-role">{o.jobTitle} · {o.department}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{o.status}</Badge></div>
</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</div>
<div className="info-grid">
<div className="info-item"><div className="il">Base Salary</div><div className="iv" style={{ fontSize: 18 }}>{money(o.base)}</div></div>
<div className="info-item"><div className="il">Annual Bonus</div><div className="iv" style={{ fontSize: 18 }}>{o.bonus}</div></div>
<div className="info-item"><div className="il">Equity</div><div className="iv" style={{ fontSize: 18 }}>{o.equity}</div></div>
<div className="info-item"><div className="il">Est. Total Cash</div><div className="iv" style={{ fontSize: 18, color: 'var(--success)' }}>{money(total)}</div></div>
</div>
</div>
</div>
<div className="info-grid">
<div className="info-item"><div className="il">Sent On</div><div className="iv">{fmtDate(o.sent)}</div></div>
<div className="info-item"><div className="il">Expires</div><div className="iv">{fmtDate(o.expires)}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{o.recruiter}</div></div>
<div className="info-item"><div className="il">Offer ID</div><div className="iv mono">{o.id}</div></div>
</div>
</Modal>
)
}
function CreateOffer({ candidates, onClose, onSave, toast }) {
const eligible = candidates.filter((c) => ['Interview', 'Offer'].includes(c.stage))
const form = useFormState({
candidate: eligible[0]?.name ?? '',
base: '', bonus: '10', equity: '', expires: '', notes: '',
})
function submit() {
if (!form.values.base || Number(form.values.base) <= 0) {
form.setErrors({ base: 'Required' })
toast('Enter a base salary', 'error')
return
}
const cand = candidates.find((c) => c.name === form.values.candidate) || candidates[0]
onSave({
id: `OFR-${9001 + candidates.length}`,
candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
jobTitle: cand.jobTitle, department: cand.department, status: 'Sent',
base: Number(form.values.base),
equity: form.values.equity || '10k RSU',
bonus: `${form.values.bonus || 10}%`,
sent: new Date(TODAY),
expires: form.values.expires ? new Date(form.values.expires) : new Date('2026-07-23'),
recruiter: cand.recruiter,
})
}
return (
<Modal
title="Create Offer"
subtitle="Generate and send an offer letter"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="send" /> Send Offer</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select value={form.values.candidate} onChange={(e) => form.setField('candidate', e.target.value)}>
{eligible.map((c) => <option key={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-field">
<label>Base Salary ($) <span className="req">*</span></label>
<input
type="number" placeholder="140000"
className={form.errors.base ? 'err' : ''}
value={form.values.base}
onChange={(e) => form.setField('base', e.target.value)}
/>
<FieldError>{form.errors.base}</FieldError>
</div>
<div className="form-field">
<label>Annual Bonus (%)</label>
<input type="number" value={form.values.bonus} onChange={(e) => form.setField('bonus', e.target.value)} />
</div>
<div className="form-field">
<label>Equity (RSU)</label>
<input placeholder="20k RSU" value={form.values.equity} onChange={(e) => form.setField('equity', e.target.value)} />
</div>
<div className="form-field">
<label>Expiration Date</label>
<input type="date" value={form.values.expires} onChange={(e) => form.setField('expires', e.target.value)} />
</div>
<div className="form-field col-span-2">
<label>Notes</label>
<textarea
placeholder="Additional details for the offer…"
value={form.values.notes}
onChange={(e) => form.setField('notes', e.target.value)}
/>
</div>
</div>
</form>
</Modal>
)
}

View File

@ -0,0 +1,143 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export 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)' },
]
/**
* Native HTML5 drag-and-drop, kept rather than swapped for a library. React
* supports draggable/onDragStart/onDragOver/onDrop as props, the frozen CSS
* already styles `.dragging` and `.drag-over`, and the prototype has no touch
* drag either so adopting @dnd-kit would be a feature addition smuggled into
* a 1:1 port. If touch kanban is wanted it is a scoped follow-up confined to
* this file.
*/
export default function Pipeline() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const updateCandidates = useSeedMutation('candidates')
const [jobId, setJobId] = useState('')
const [draggingId, setDraggingId] = useState(null)
const [overStage, setOverStage] = useState(null)
const list = useMemo(
() => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates),
[candidates, jobId],
)
const byStage = useMemo(() => {
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
for (const c of list) if (map[c.stage]) map[c.stage].push(c)
return map
}, [list])
function onDrop(stage) {
setOverStage(null)
const id = draggingId
setDraggingId(null)
if (!id) return
const cand = candidates.find((c) => c.id === id)
if (!cand || cand.stage === stage) return
// Mutating the cache re-renders every screen reading candidates, so the
// move is visible on Candidates and Talent Pool too.
updateCandidates((cs) => cs.map((c) => (c.id === id ? { ...c, stage, status: stage } : c)))
toast(`${cand.name} moved to ${stage}`, 'success')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Pipeline</h1>
<p className="page-sub">Drag candidates between stages to update their status</p>
</div>
<div className="page-head-actions">
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.filter((j) => j.status === 'Open').map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => navigate('/candidates', { state: { openAdd: true } })}
>
<Icon name="plus" /> Add Candidate
</button>
</div>
</div>
<div className="kanban">
{KANBAN_STAGES.map((st) => {
const cards = byStage[st.name] ?? []
return (
<div className="kanban-col" key={st.name}>
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOverStage(st.name) }}
onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))}
onDrop={(e) => { e.preventDefault(); onDrop(st.name) }}
>
{cards.map((c) => (
<div
key={c.id}
className={`k-card${draggingId === c.id ? ' dragging' : ''}`}
draggable
onDragStart={(e) => {
setDraggingId(c.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', c.id)
}}
onDragEnd={() => { setDraggingId(null); setOverStage(null) }}
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
navigate('/candidates', { state: { openCandidate: c.id } })
}}
>
<div className="k-card-top">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="kc-name">{c.name}</div>
<div className="kc-role">{c.currentTitle}</div>
</div>
</div>
<div className="kc-role">{c.jobTitle}</div>
<div className="k-tags">
{c.skills.slice(0, 3).map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
<div className="k-card-meta">
<span className="cell-sub">{c.currentCompany}</span>
<ScoreChip score={c.aiScore} />
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,258 @@
/* ============================================================
Access Control one of the three screens with a real backend.
The prototype's 13 modules x 8 permission types map EXACTLY onto the
backend's 104-tag vocabulary (same modules, same actions, same order), so the
matrix can render real server truth instead of an invented boolean grid.
HONESTY NOTE: the prototype's "Save Changes" fired a success toast and saved
nothing, and its matrix gated nothing (01-repository-assessment.md §2.4). The
backend grants permissions through *bundles* (`roles.permissions` is a list of
bundle ids), not per-tag, so an arbitrary tag set is not expressible through
`PUT /roles/update`. Rather than reproduce a lying save button, the matrix
shows resolved `effective_permissions` read-only and says where they come
from. Creating a role is a real POST.
============================================================ */
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as rolesApi from '../api/roles'
import { permTypes, rbacModules } from '../data/seed'
// Prototype label -> backend module slug. Order matches, so this is positional.
const MODULE_SLUGS = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users',
]
const ACTION_SLUGS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']
export default function Rbac() {
const { toast } = useToast()
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [creating, setCreating] = useState(false)
const rolesQuery = useQuery({
queryKey: qk.roles.list(),
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
})
const createRole = useMutation({
mutationFn: (body) => rolesApi.createRole(body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.roles.all() })
setCreating(false)
toast('Role created', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not create the role.'), 'error'),
})
const roles = rolesQuery.data ?? []
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
// effective_permissions is a flat list of "module.action" tags.
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Access Control</h1>
<p className="page-sub">
Enterprise RBAC roles, permission bundles and the 104-tag vocabulary, live from the server
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> New Role
</button>
</div>
</div>
{rolesQuery.isPending && (
<div className="card"><div className="card-body"><EmptyState icon="clock" title="Loading roles">Fetching from the server</EmptyState></div></div>
)}
{rolesQuery.isError && (
<div className="card">
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(rolesQuery.error, 'The server did not return the role list.')}
{' '}This screen needs the <code>rbac_users.view</code> permission.
</EmptyState>
</div>
</div>
)}
{rolesQuery.isSuccess && (
<div className="rbac-layout">
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-body" style={{ padding: 12 }}>
<div className="nav-section-label" style={{ padding: '6px 8px' }}>Roles</div>
<div className="role-list">
{roles.map((r, i) => (
<div
key={r.id}
className={`role-item${r.id === role?.id ? ' active' : ''}`}
onClick={() => setSelectedId(r.id)}
>
<span className="role-badge" style={{ background: ROLE_COLORS[i % ROLE_COLORS.length] }}>
<Icon name="shield" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.role_name}</div>
<div className="cell-sub">
{(r.effective_permissions?.length ?? 0)} permissions
{r.is_system ? ' · system' : ''}
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="card">
{role && (
<>
<div className="card-head">
<div className="flex items-center gap-12">
<span className="role-badge" style={{ background: ROLE_COLORS[roles.indexOf(role) % ROLE_COLORS.length] }}>
<Icon name="shield" />
</span>
<div>
<h3>{role.role_name}</h3>
<span className="ch-sub">{role.description || 'No description'}</span>
</div>
</div>
<div className="flex items-center gap-8">
{role.is_system && <Badge className="b-gray">System role</Badge>}
<span className="badge b-gray badge-plain">
{role.effective_permissions?.length ?? 0} / 104
</span>
</div>
</div>
<div className="card-body">
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" /> These are the roles <b>resolved</b> permissions. The server grants
them through permission bundles
{role.bundles?.length ? ` (${role.bundles.map((b) => b.name ?? b).join(', ')})` : ''},
so individual cells are not directly editable here.
</p>
<div className="table-wrap">
<table className="rbac-matrix">
<thead>
<tr>
<th>Module</th>
{permTypes.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{rbacModules.map((label, mi) => (
<tr key={label}>
<td>{label}</td>
{ACTION_SLUGS.map((action, ai) => {
const on = granted.has(`${MODULE_SLUGS[mi]}.${action}`)
return (
<td key={action}>
<span
className={`perm-check${on ? ' on' : ''}`}
title={`${MODULE_SLUGS[mi]}.${action}`}
aria-label={`${label} ${permTypes[ai]}: ${on ? 'granted' : 'not granted'}`}
>
<Icon name="check" />
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</div>
)}
{creating && (
<CreateRole
busy={createRole.isPending}
onClose={() => setCreating(false)}
onSave={(body) => createRole.mutate(body)}
/>
)}
</div>
)
}
function CreateRole({ busy, onClose, onSave }) {
const form = useFormState({ role_name: '', description: '' })
function submit() {
if (!form.values.role_name.trim()) {
form.setErrors({ role_name: 'Required' })
return
}
onSave({
role_name: form.values.role_name.trim(),
description: form.values.description.trim() || 'Custom role',
permissions: [],
is_active: true,
})
}
return (
<Modal
title="Create Role"
subtitle="Define a new access role"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy}>
<Icon name="check" /> {busy ? 'Creating…' : 'Create Role'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Role Name <span className="req">*</span></label>
<input
placeholder="e.g. Regional Recruiter"
className={form.errors.role_name ? 'err' : ''}
value={form.values.role_name}
onChange={(e) => form.setField('role_name', e.target.value)}
/>
<FieldError>{form.errors.role_name}</FieldError>
</div>
<div className="form-field col-span-2">
<label>Description</label>
<input
placeholder="What can this role do?"
value={form.values.description}
onChange={(e) => form.setField('description', e.target.value)}
/>
</div>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
The role starts with no permission bundles. Assign bundles server-side to grant it access.
</p>
</form>
</Modal>
)
}

View File

@ -0,0 +1,181 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
import { Avatar, Badge, Icon, KpiCard } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { analytics, int } from '../data/seed'
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKS = ['W1', 'W2', 'W3', 'W4', 'W5']
const STAGES = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const MAX_HEAT = 5
const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (v / MAX_HEAT) * 0.8})`)
export default function RecruiterHub() {
const { toast } = useToast()
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const [recId, setRecId] = useState(null)
const r = recruiters.find((x) => x.id === recId) ?? recruiters[0]
const trendData = useMemo(
() =>
r
? {
labels: analytics.hiringTrend.labels,
area: true,
datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }],
}
: null,
[r],
)
// The prototype re-rolled these counts on every render via DB.int(). Keyed to
// the recruiter so they're stable while you look at one.
const pipelineData = useMemo(
() => ({ labels: STAGES, data: STAGES.map(() => int(2, 14)), colors: Charts.PALETTE }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[r?.id],
)
const board = useMemo(() => [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8), [recruiters])
if (!r) return null
const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruiter Hub</h1>
<p className="page-sub">Personalized performance dashboard &amp; workload</p>
</div>
<div className="page-head-actions">
<select className="select" value={r.id} onChange={(e) => setRecId(e.target.value)}>
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
<button className="btn btn-secondary" onClick={() => toast('Report exported', 'success')}>
<Icon name="download" /> Export
</button>
</div>
</div>
<div className="card brand-hero mb-18">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
<Avatar name={r.name} initials={r.initials} color="rgba(255,255,255,.18)" className="avatar-lg" />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{r.name}</div>
<div style={{ opacity: 0.85 }}>{r.department} Recruiter · {r.rating} rating</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.workload}%</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Workload</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.efficiency}%</div>
<div style={{ opacity: 0.85, fontSize: 12 }}>Efficiency</div>
</div>
<div style={{ textAlign: 'center' }}><Badge className={slaCls}>{r.sla}</Badge></div>
</div>
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Open Positions" value={r.openPositions} icon="briefcase" tone="i-indigo" foot="active reqs" />
<KpiCard label="Closed Positions" value={r.closedPositions} icon="check-circle" tone="i-green" foot="this year" />
<KpiCard label="Avg Time to Hire" value={`${r.avgTimeToHire}d`} icon="clock" tone="i-teal" foot="target 30d" />
<KpiCard label="Avg Time to Fill" value={`${r.avgTimeToFill}d`} icon="target" tone="i-amber" foot="req → offer" />
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Interviews Today" value={r.interviewsToday} icon="calendar" tone="i-purple" />
<KpiCard label="Offers Pending" value={r.offersPending} icon="file" tone="i-blue" />
<KpiCard label="Awaiting Approval" value={r.jobsAwaitingApproval} icon="clock" tone="i-amber" />
<KpiCard label="Jobs Overdue" value={r.jobsOverdue} icon="alert" tone="i-red" />
</div>
<div className="grid g-kpi mb-18">
<KpiCard label="Conversion Rate" value={`${r.conversionRate}%`} icon="trending-up" tone="i-green" foot="applicant → hire" />
<KpiCard label="Interview Completion" value={`${r.interviewCompletion}%`} icon="check-square" tone="i-teal" />
<KpiCard label="Avg Response Time" value={`${r.avgResponseTime}h`} icon="zap" tone="i-purple" foot="to candidates" />
<KpiCard label="TAT Performance" value={`${r.tat}%`} icon="award" tone="i-indigo" foot="turnaround" />
</div>
<div className="grid g-2-1 mb-18">
<div className="card">
<div className="card-head"><div><h3>Monthly Hiring Trend</h3><span className="ch-sub">Hires per month</span></div></div>
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={trendData} height={260} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Workload Heatmap</h3><span className="ch-sub">Interview load</span></div></div>
<div className="card-body">
<div className="heatmap">
<div className="hm-label" />
{WEEKS.map((w) => <div className="hm-label" style={{ justifyContent: 'center' }} key={w}>{w}</div>)}
{DAYS.map((d, di) => (
<div style={{ display: 'contents' }} key={d}>
<div className="hm-label">{d}</div>
{r.heatmap[di].map((v, wi) => (
<div
className="hm-cell"
key={`${d}-${wi}`}
style={{ background: heatColor(v) }}
data-tip={`${v} interviews`}
/>
))}
</div>
))}
</div>
<div className="hm-legend">
Less
{[0, 1, 2, 3, 5].map((v) => (
<span className="hm-box" key={v} style={{ background: heatColor(v) }} />
))}
More
</div>
</div>
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head"><div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div></div>
<div className="card-body">
{board.map((rec, i) => (
<div
className="leader-row"
key={rec.id}
style={
rec.id === r.id
? { background: 'var(--primary-soft)', borderRadius: 10, paddingLeft: 8, paddingRight: 8 }
: undefined
}
>
<span className={`leader-rank ${i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''}`}>
{i + 1}
</span>
<Avatar name={rec.name} initials={rec.initials} color={rec.color} />
<div className="lr-main">
<div className="lr-title">{rec.name}</div>
<div className="lr-sub">{rec.efficiency}% efficiency · {rec.avgTimeToHire}d avg</div>
</div>
<div className="lr-right">
<div className="fw-600">{rec.hires}</div>
<div className="lr-sub">hires</div>
</div>
</div>
))}
</div>
</div>
<div className="card">
<div className="card-head">
<div><h3>Candidate Pipeline</h3><span className="ch-sub">This recruiters active candidates</span></div>
</div>
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipelineData} height={260} /></div></div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,173 @@
import { useMemo } from 'react'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import DataTable from '../ui/DataTable'
import { Icon, KpiCard, ProgressBar } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { analytics as a, int } from '../data/seed'
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 },
]
const REPORT_TYPES = [
{ 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' },
]
export default function Reports() {
const { toast } = useToast()
// The prototype generated hires/ttf inline at render time via DB.int(), so
// they changed on every re-render. Computed once here instead.
const deptRows = useMemo(
() =>
a.departments.map((d) => {
const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100)
return {
id: d.dept, dept: d.dept, open: d.open, apps: d.apps,
hires: int(1, 8), ttf: int(28, 52), rate: Math.min(rate, 98),
}
}),
[],
)
const funnelData = useMemo(
() => ({
labels: FUNNEL.map((f) => f.stage),
data: FUNNEL.map((f) => f.v),
colors: Charts.PALETTE,
yFmt: (v) => `${v}%`,
}),
[],
)
const timeData = useMemo(
() => ({
labels: a.hiringTrend.labels,
datasets: [
{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] },
{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] },
],
yFmt: (v) => `${v}d`,
}),
[],
)
const timeLegend = useMemo(
() => [
{ label: 'Time to Hire', color: Charts.PALETTE[0] },
{ label: 'Time to Fill', color: Charts.PALETTE[2] },
],
[],
)
const cards = [
{ label: 'Total Hires (YTD)', value: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icon: 'award', tone: 'i-green', foot: '+18% vs last year' },
{ label: 'Total Applications', value: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icon: 'users', tone: 'i-blue', foot: 'across all channels' },
{ label: 'Avg. Time to Hire', value: '27 days', icon: 'clock', tone: 'i-teal', foot: '3 days faster' },
{ label: 'Avg. Cost per Hire', value: '$4,280', icon: 'dollar', tone: 'i-amber', foot: 'within budget' },
]
const columns = [
{ key: 'dept', label: 'Department', sortable: true, render: (r) => <span className="cell-primary">{r.dept}</span> },
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => <b>{r.apps}</b> },
{ 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) => (
<div className="flex items-center gap-8">
<div style={{ flex: 1 }}><ProgressBar pct={r.rate} /></div>
<b style={{ width: 38, textAlign: 'right' }}>{r.rate}%</b>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Reports</h1>
<p className="page-sub">Recruitment metrics and downloadable insights</p>
</div>
<div className="page-head-actions">
<select className="select" defaultValue="Last 7 months">
<option>Last 7 months</option>
<option>This quarter</option>
<option>This year</option>
</select>
<button className="btn btn-primary" onClick={() => toast('Full report exported to PDF', 'success')}>
<Icon name="download" /> Export Report
</button>
</div>
</div>
<div className="grid g-kpi mb-18">
{cards.map((c) => <KpiCard key={c.label} {...c} />)}
</div>
<div className="grid g-2 mb-18">
<div className="card">
<div className="card-head">
<div><h3>Hiring Funnel</h3><span className="ch-sub">Stage-by-stage conversion</span></div>
<button className="btn btn-ghost btn-sm" onClick={() => toast('Chart exported', 'info')}>
<Icon name="download" />
</button>
</div>
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={funnelData} height={280} /></div></div>
</div>
<div className="card">
<div className="card-head"><div><h3>Time to Hire vs Fill</h3><span className="ch-sub">Monthly trend (days)</span></div></div>
<div className="card-body">
<div className="chart-wrap"><Chart type="groupedBar" data={timeData} height={280} /></div>
<ChartLegend items={timeLegend} />
</div>
</div>
</div>
<div className="card mb-18">
<div className="card-head">
<div><h3>Department Performance</h3><span className="ch-sub">Hiring breakdown by team</span></div>
<button className="btn btn-secondary btn-sm" onClick={() => toast('Table exported to CSV', 'success')}>
<Icon name="download" /> CSV
</button>
</div>
<DataTable columns={columns} rows={deptRows} pageSize={10} />
</div>
<div className="card">
<div className="card-head"><div><h3>Report Library</h3><span className="ch-sub">Generate a detailed report</span></div></div>
<div className="card-body">
<div className="grid g-3">
{REPORT_TYPES.map((r) => (
<div
key={r.name}
className="card"
style={{ boxShadow: 'none', background: 'var(--bg-sunken)', cursor: 'pointer' }}
onClick={() => toast(`Generating: ${r.name}`, 'info')}
>
<div className="card-body">
<span className={`kpi-icn ${r.cls}`} style={{ marginBottom: 12 }}><Icon name={r.icn} /></span>
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ marginTop: 4 }}>{r.desc}</div>
<div style={{ marginTop: 12, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>
Generate <Icon name="chevron-right" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,463 @@
/* ============================================================
Settings 10 tabs. Two are real, eight are inert chrome exactly as in the
prototype.
The Users tab is wired to GET /users/fetch, and Appearance drives the real
ThemeProvider. Everything else (General, Roles, Permissions, Notifications,
Email Templates, Career Portal, Branding, Security) is markup with no
persistence same as the prototype.
The Security tab in particular renders 2FA and audit logging as ENABLED while
enforcing nothing; 01-repository-assessment.md §2.4 calls that out as
"actively dangerous as a demo artefact". A standing notice is rendered above
it rather than silently reproducing the claim.
============================================================ */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as usersApi from '../api/users'
import { roles as seedRoles } from '../data/seed'
const TABS = [
'General', 'Users', 'Roles', 'Permissions', 'Notifications',
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
]
function ToggleRow({ title, desc, defaultChecked }) {
const { toast } = useToast()
return (
<div className="setting-row">
<div className="setting-info"><h4>{title}</h4><p>{desc}</p></div>
<label className="switch">
<input
type="checkbox"
defaultChecked={defaultChecked}
onChange={() => toast('Preference updated', 'success')}
/>
<span className="switch-track" />
</label>
</div>
)
}
export default function Settings() {
const { toast } = useToast()
const [tab, setTab] = useState('General')
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-sub">Configure your workspace and team preferences</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Settings saved', 'success')}>
<Icon name="check" /> Save Changes
</button>
</div>
</div>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
<div className="tab-pane active">
{tab === 'General' && <General />}
{tab === 'Users' && <Users />}
{tab === 'Roles' && <Roles />}
{tab === 'Permissions' && <Permissions />}
{tab === 'Notifications' && <Notifications />}
{tab === 'Email Templates' && <EmailTemplates />}
{tab === 'Career Portal' && <CareerPortal />}
{tab === 'Branding' && <Branding />}
{tab === 'Security' && <Security />}
{tab === 'Appearance' && <Appearance />}
</div>
</div>
)
}
function General() {
return (
<div className="card">
<div className="card-body">
<div className="form-grid">
<div className="form-field"><label>Organization Name</label><input defaultValue="Utopia Brands Inc." /></div>
<div className="form-field"><label>Company Website</label><input defaultValue="https://utopiabrands.com" /></div>
<div className="form-field">
<label>Industry</label>
<select><option>Consumer Goods</option><option>Technology</option><option>Retail</option></select>
</div>
<div className="form-field">
<label>Company Size</label>
<select><option>201500</option><option>51200</option><option>500+</option></select>
</div>
<div className="form-field">
<label>Default Time Zone</label>
<select>
<option>(GMT-08:00) Pacific Time</option>
<option>(GMT-05:00) Eastern Time</option>
<option>(GMT+00:00) UTC</option>
</select>
</div>
<div className="form-field">
<label>Default Currency</label>
<select><option>USD ($)</option><option>EUR ()</option><option>GBP (£)</option></select>
</div>
</div>
<div className="divider" />
<ToggleRow title="Auto-archive stale jobs" desc="Automatically close requisitions inactive for 90 days" defaultChecked />
<ToggleRow title="Duplicate detection" desc="Flag candidates that already exist in the system" defaultChecked />
</div>
</div>
)
}
/** Real data: GET /users/fetch (requires rbac_users.view). */
function Users() {
const { toast } = useToast()
const usersQuery = useQuery({
queryKey: qk.users.list(),
queryFn: () => usersApi.list({ top: 50 }).then((r) => r.data ?? []),
})
const users = usersQuery.data ?? []
return (
<div className="card">
<div className="card-head">
<div>
<h3>Team Members</h3>
<span className="ch-sub">
{usersQuery.isPending ? 'Loading…' : `${users.length} users`}
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={() => toast('Invite sent', 'success')}>
<Icon name="plus" /> Invite User
</button>
</div>
{usersQuery.isError ? (
<div className="card-body">
<div className="empty-state">
<Icon name="alert" />
<h3>Couldnt load users</h3>
<p>
{friendlyAuthError(usersQuery.error, 'The server did not return the user list.')}
{' '}This tab needs the <code>rbac_users.view</code> permission.
</p>
</div>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>User</th><th>Role</th><th>Status</th><th>Created</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name} />
<div>
<div className="cell-primary">{u.name}</div>
<div className="cell-sub">{u.email}</div>
</div>
</div>
</td>
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
<td><Badge>{u.is_active ? 'Active' : 'Pending'}</Badge></td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" onClick={() => toast(`Editing ${u.name}`, 'info')}>
<Icon name="edit" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
function Roles() {
const { toast } = useToast()
return (
<div className="card">
<div className="card-head">
<div><h3>Roles</h3><span className="ch-sub">Define access levels</span></div>
<button className="btn btn-primary btn-sm" onClick={() => toast('New role dialog', 'info')}>
<Icon name="plus" /> Add Role
</button>
</div>
<div className="card-body">
<div className="list-tight">
{seedRoles.map((r) => (
<div className="list-row" key={r.name}>
<span className="kpi-icn i-purple" style={{ width: 40, height: 40, borderRadius: 11 }}>
<Icon name="users" />
</span>
<div className="lr-main"><div className="lr-title">{r.name}</div><div className="lr-sub">{r.desc}</div></div>
<div className="lr-right"><div className="fw-600">{r.users} users</div><div className="lr-sub">{r.perms}</div></div>
<button className="act-btn" onClick={() => toast(`Editing ${r.name} role`, 'info')}>
<Icon name="edit" />
</button>
</div>
))}
</div>
</div>
</div>
)
}
function Permissions() {
const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']
const perms = ['View', 'Create', 'Edit', 'Delete']
const { toast } = useToast()
return (
<div className="card">
<div className="card-head">
<div><h3>Permission Matrix</h3><span className="ch-sub">Recruiter role</span></div>
<select className="select"><option>Recruiter</option><option>Hiring Manager</option><option>Administrator</option></select>
</div>
<p className="text-muted text-sm" style={{ padding: '0 18px' }}>
This is a simplified view. The authoritative matrix 13 modules × 8 actions, resolved from the
server lives on <b>Access Control</b>.
</p>
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Module</th>{perms.map((p) => <th style={{ textAlign: 'center' }} key={p}>{p}</th>)}</tr>
</thead>
<tbody>
{modules.map((m) => (
<tr key={m}>
<td className="cell-primary">{m}</td>
{perms.map((p) => (
<td style={{ textAlign: 'center' }} key={p}>
<label className="switch">
<input
type="checkbox"
defaultChecked={m !== 'Settings'}
onChange={() => toast('Permission updated', 'success')}
/>
<span className="switch-track" />
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function Notifications() {
return (
<div className="card">
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</div>
<ToggleRow title="New applications" desc="Get notified when a candidate applies" defaultChecked />
<ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" defaultChecked />
<ToggleRow title="Offer responses" desc="When candidates accept or decline offers" defaultChecked />
<ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" />
<div className="form-section-title">In-App Notifications</div>
<ToggleRow title="Mentions" desc="When a teammate @mentions you" defaultChecked />
<ToggleRow title="Stage changes" desc="When a candidate moves stages" />
<ToggleRow title="Task assignments" desc="When you are assigned a task" defaultChecked />
</div>
</div>
)
}
function EmailTemplates() {
const { toast } = useToast()
const templates = [
'Application Received', 'Interview Invitation', 'Assessment Assignment',
'Offer Letter', 'Rejection — Post Interview', 'Reference Request',
]
return (
<div className="card">
<div className="card-head">
<div><h3>Email Templates</h3></div>
<button className="btn btn-primary btn-sm" onClick={() => toast('New template', 'info')}>
<Icon name="plus" /> New Template
</button>
</div>
<div className="card-body">
<div className="list-tight">
{templates.map((t) => (
<div className="list-row" key={t}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="mail" />
</span>
<div className="lr-main"><div className="lr-title">{t}</div><div className="lr-sub">Last edited 3 days ago</div></div>
<Badge className="b-green">Active</Badge>
<button className="act-btn" onClick={() => toast('Editing template', 'info')}><Icon name="edit" /></button>
</div>
))}
</div>
</div>
</div>
)
}
function CareerPortal() {
return (
<div className="card">
<div className="card-body">
<div className="form-grid">
<div className="form-field col-span-2"><label>Careers Page URL</label><input defaultValue="https://careers.utopiabrands.com" /></div>
<div className="form-field"><label>Page Headline</label><input defaultValue="Build the future with us" /></div>
<div className="form-field"><label>Primary CTA Text</label><input defaultValue="View Open Roles" /></div>
</div>
<div className="divider" />
<ToggleRow title="Public job board" desc="Make open roles visible to the public" defaultChecked />
<ToggleRow title="Allow one-click apply" desc="Let candidates apply with LinkedIn" defaultChecked />
<ToggleRow title="Show salary ranges" desc="Display compensation on job listings" />
<ToggleRow title="Enable referrals" desc="Employees can refer candidates" defaultChecked />
</div>
</div>
)
}
function Branding() {
const { toast } = useToast()
const colors = ['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4']
return (
<div className="card">
<div className="card-body">
<div className="setting-row">
<div className="setting-info"><h4>Company Logo</h4><p>Displayed on career pages and emails</p></div>
<div className="flex items-center gap-12">
<span className="brand-logo" style={{ width: 48, height: 48 }}>UB</span>
<button className="btn btn-secondary btn-sm" onClick={() => toast('Upload dialog', 'info')}>Upload</button>
</div>
</div>
<div className="setting-row">
<div className="setting-info"><h4>Brand Color</h4><p>Primary accent across the portal</p></div>
<div className="flex items-center gap-8">
{colors.map((c) => (
<span
key={c}
style={{ width: 28, height: 28, borderRadius: 8, background: c, cursor: 'pointer', border: '2px solid var(--border)' }}
onClick={() => toast('Brand color updated', 'success')}
/>
))}
</div>
</div>
<div className="form-grid" style={{ marginTop: 16 }}>
<div className="form-field"><label>Email Footer</label><input defaultValue="Utopia Brands · San Francisco, CA" /></div>
<div className="form-field"><label>Support Email</label><input defaultValue="talent@utopiabrands.com" /></div>
</div>
</div>
</div>
)
}
function Security() {
return (
<div className="card">
<div className="card-body">
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
<b>Not yet enforced.</b> These controls are interface only none of them is wired to the
backend, which today has no 2FA, no SSO, no IP allowlist and no audit log. Do not read the
toggles below as a statement of what is switched on.
</div>
<ToggleRow title="Two-factor authentication" desc="Require 2FA for all team members" defaultChecked />
<ToggleRow title="Single Sign-On (SSO)" desc="Enable SAML-based SSO login" />
<ToggleRow title="IP allowlist" desc="Restrict access to approved IP ranges" />
<ToggleRow title="Audit logging" desc="Track all data access and changes" defaultChecked />
<div className="form-grid" style={{ marginTop: 16 }}>
<div className="form-field">
<label>Session Timeout</label>
<select><option>30 minutes</option><option>1 hour</option><option>8 hours</option></select>
</div>
<div className="form-field">
<label>Password Policy</label>
<select><option>Strong (12+ chars)</option><option>Medium (8+ chars)</option></select>
</div>
</div>
<div className="divider" />
<div className="setting-row">
<div className="setting-info"><h4>Data Retention</h4><p>Auto-delete candidate data after set period</p></div>
<select className="select"><option>24 months</option><option>12 months</option><option>36 months</option></select>
</div>
</div>
</div>
)
}
/** The one tab in the prototype that actually did something. */
function Appearance() {
const { toast } = useToast()
const { setTheme, useSystemTheme } = useTheme()
function pick(mode) {
if (mode === 'system') useSystemTheme()
else setTheme(mode)
toast(`Theme updated to ${mode}`, 'success')
}
return (
<div className="card">
<div className="card-body">
<div className="form-section-title" style={{ marginTop: 0 }}>Theme</div>
<div className="grid g-3" style={{ marginBottom: 8 }}>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('light')}>
<div style={{ height: 80, background: '#f1f7f4', borderBottom: '1px solid var(--border)', display: 'flex' }}>
<div style={{ width: '30%', background: '#1a3134' }} />
<div style={{ flex: 1, padding: 10 }}>
<div style={{ height: 8, background: '#fff', borderRadius: 4, marginBottom: 6 }} />
<div style={{ height: 8, width: '45%', background: '#004d43', borderRadius: 4 }} />
</div>
</div>
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">Light</div><div className="lr-sub">Clean and bright</div>
</div>
</div>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('dark')}>
<div style={{ height: 80, background: '#0e1d1f', borderBottom: '1px solid var(--border)', display: 'flex' }}>
<div style={{ width: '30%', background: '#0a1618' }} />
<div style={{ flex: 1, padding: 10 }}>
<div style={{ height: 8, background: '#24403f', borderRadius: 4, marginBottom: 6 }} />
<div style={{ height: 8, width: '45%', background: '#ceff71', borderRadius: 4 }} />
</div>
</div>
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">Dark</div><div className="lr-sub">Easy on the eyes</div>
</div>
</div>
<div className="card theme-opt" style={{ cursor: 'pointer', overflow: 'hidden' }} onClick={() => pick('system')}>
<div style={{ height: 80, background: 'linear-gradient(90deg,#f1f7f4 50%,#0e1d1f 50%)', borderBottom: '1px solid var(--border)' }} />
<div className="card-body" style={{ padding: 12 }}>
<div className="fw-600">System</div><div className="lr-sub">Match OS setting</div>
</div>
</div>
</div>
<div className="divider" />
<ToggleRow title="Compact mode" desc="Reduce spacing for denser layouts" />
<ToggleRow title="Show animations" desc="Enable transitions and motion" defaultChecked />
</div>
</div>
)
}

View File

@ -0,0 +1,100 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { departments } from '../data/seed'
export default function TalentPool() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
// Silver medalists / passive talent candidates outside the active loop.
const pool = useMemo(
() => candidates.filter((c) => ['Rejected', 'Applied', 'Hired'].includes(c.stage)),
[candidates],
)
const list = useMemo(
() =>
pool.filter((c) => {
if (dept && c.department !== dept) return false
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
return true
}),
[pool, q, dept],
)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Talent Pool</h1>
<p className="page-sub">{pool.length} silver-medalists &amp; passive candidates to re-engage</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
<Icon name="send" /> Start Campaign
</button>
</div>
</div>
<div className="card mb-18">
<div className="card-body" style={{ padding: 16 }}>
<div className="toolbar" style={{ marginBottom: 0 }}>
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
</div>
</div>
</div>
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
</div>
) : (
list.map((c) => (
<div
key={c.id}
className="card"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.currentTitle}</div>
</div>
<ScoreChip score={c.aiScore} />
</div>
<div className="k-tags" style={{ marginBottom: 12 }}>
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
<span className="cell-sub"><Icon name="briefcase" /> {c.experience} yrs</span>
<span className="cell-sub">{c.currentCompany}</span>
<Badge className="b-gray">{c.source}</Badge>
</div>
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,282 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { fmtDate, fmtShort, getCandidate, savedSearches, TODAY } from '../data/seed'
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
export default function Tasks() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateTasks = useSeedMutation('tasks')
const [filter, setFilter] = useState('All')
const [detail, setDetail] = useState(null)
const [adding, setAdding] = useState(false)
const isOverdue = (t) => !t.done && t.due < TODAY
const list = useMemo(() => {
if (filter === 'Open') return tasks.filter((t) => !t.done)
if (filter === 'Completed') return tasks.filter((t) => t.done)
if (filter === 'Overdue') return tasks.filter(isOverdue)
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
return tasks
}, [tasks, filter])
const openCount = tasks.filter((t) => !t.done).length
const overdueCount = tasks.filter(isOverdue).length
// Writing to the cache is what makes the sidebar badge update the prototype
// had to remember to call App.updateBadges() at each of these call sites.
function toggle(id) {
let nowDone = false
updateTasks((ts) =>
ts.map((t) => {
if (t.id !== id) return t
nowDone = !t.done
return { ...t, done: nowDone }
}),
)
toast(nowDone ? 'Task completed' : 'Task reopened', nowDone ? 'success' : 'info')
}
function complete(id) {
updateTasks((ts) => ts.map((t) => (t.id === id ? { ...t, done: true } : t)))
toast('Task completed', 'success')
}
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Tasks</h1>
<p className="page-sub">{openCount} open · {overdueCount} overdue</p>
</div>
<div className="page-head-actions">
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> New Task
</button>
</div>
</div>
<div className="grid g-2-1">
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="seg">
{FILTERS.map((f) => (
<button key={f} className={f === filter ? 'active' : ''} onClick={() => setFilter(f)}>
{f}
</button>
))}
</div>
</div>
<div className="card-body">
<div className="list-tight">
{list.length === 0 ? (
<EmptyState icon="check-square" title="All caught up">No tasks in this view.</EmptyState>
) : (
list.map((t) => {
const overdue = isOverdue(t)
return (
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
<span
className={`checkbox ${t.done ? 'on' : ''}`}
onClick={(e) => { e.stopPropagation(); toggle(t.id) }}
role="checkbox"
aria-checked={t.done}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t.id) } }}
>
<Icon name="check" />
</span>
<div className="lr-main" style={{ cursor: 'pointer' }} onClick={() => setDetail(t)}>
<div
className="lr-title"
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
>
{t.title}
</div>
<div className="lr-sub"><Icon name="users" /> {t.assignee} · {t.type}</div>
</div>
<div className="lr-right">
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
<div
className="lr-sub"
style={{ marginTop: 4, ...(overdue ? { color: 'var(--danger)', fontWeight: 600 } : {}) }}
>
{overdue ? 'Overdue · ' : 'Due '}{fmtShort(t.due)}
</div>
</div>
</div>
)
})
)}
</div>
</div>
</div>
<div className="card" style={{ alignSelf: 'start' }}>
<div className="card-head">
<div><h3>Saved Searches</h3><span className="ch-sub">Quick candidate filters</span></div>
<button className="act-btn" onClick={() => toast('New saved search', 'info')}><Icon name="plus" /></button>
</div>
<div className="card-body">
<div className="list-tight">
{savedSearches.map((s) => (
<div key={s.name} className="list-row" style={{ cursor: 'pointer' }} onClick={() => navigate('/candidates')}>
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name="bookmark" />
</span>
<div className="lr-main">
<div className="lr-title">{s.name}</div>
<div className="lr-sub">{s.filters}</div>
</div>
<span className="badge b-gray badge-plain">{s.count}</span>
</div>
))}
</div>
</div>
</div>
</div>
{detail && (
<TaskDetail
task={detail}
onClose={() => setDetail(null)}
onComplete={() => { complete(detail.id); setDetail(null) }}
onViewCandidate={(id) => { setDetail(null); navigate('/candidates', { state: { openCandidate: id } }) }}
/>
)}
{adding && (
<AddTask
recruiters={recruiters}
count={tasks.length}
onClose={() => setAdding(false)}
onSave={(task) => {
updateTasks((ts) => [task, ...ts])
setAdding(false)
toast('Task created', 'success')
}}
/>
)}
</div>
)
}
function TaskDetail({ task: t, onClose, onComplete, onViewCandidate }) {
const c = t.candidateId ? getCandidate(t.candidateId) : null
return (
<Modal
title={t.title}
subtitle={`${t.id} · ${t.type}`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
{c && (
<button className="btn btn-secondary" onClick={() => onViewCandidate(c.id)}>View Candidate</button>
)}
<button className="btn btn-primary" onClick={onComplete}><Icon name="check" /> Mark Complete</button>
</>
}
>
<div className="info-grid" style={{ marginBottom: 16 }}>
<div className="info-item"><div className="il">Assignee</div><div className="iv">{t.assignee}</div></div>
<div className="info-item"><div className="il">Priority</div><div className="iv">{t.priority}</div></div>
<div className="info-item"><div className="il">Due Date</div><div className="iv">{fmtDate(t.due)}</div></div>
<div className="info-item"><div className="il">Status</div><div className="iv">{t.done ? 'Completed' : 'Open'}</div></div>
{c && <div className="info-item"><div className="il">Candidate</div><div className="iv">{c.name}</div></div>}
</div>
<div className="form-field">
<label>Notes</label>
<textarea placeholder="Add task notes…" />
</div>
</Modal>
)
}
function AddTask({ recruiters, count, onClose, onSave }) {
const form = useFormState({
title: '', priority: 'Medium', type: 'Interview',
assignee: recruiters[0]?.name ?? '', due: '',
})
function submit() {
if (!form.values.title.trim()) {
form.setErrors({ title: 'Required' })
return
}
onSave({
id: `TSK-${50001 + count}`,
title: form.values.title,
candidateId: null,
priority: form.values.priority,
due: form.values.due ? new Date(form.values.due) : new Date('2026-07-16'),
assignee: form.values.assignee,
done: false,
type: form.values.type,
})
}
return (
<Modal
title="New Task"
subtitle="Create a recruitment task"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Create Task</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Task Title <span className="req">*</span></label>
<input
placeholder="e.g. Screen candidate"
className={form.errors.title ? 'err' : ''}
value={form.values.title}
onChange={(e) => form.setField('title', e.target.value)}
/>
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Priority</label>
<select value={form.values.priority} onChange={(e) => form.setField('priority', e.target.value)}>
<option>High</option><option>Medium</option><option>Low</option>
</select>
</div>
<div className="form-field">
<label>Type</label>
<select value={form.values.type} onChange={(e) => form.setField('type', e.target.value)}>
{['Interview', 'Review', 'Offer', 'Admin'].map((o) => <option key={o}>{o}</option>)}
</select>
</div>
<div className="form-field">
<label>Assignee</label>
<select value={form.values.assignee} onChange={(e) => form.setField('assignee', e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<div className="form-field">
<label>Due Date</label>
<input type="date" value={form.values.due} onChange={(e) => form.setField('due', e.target.value)} />
</div>
</div>
</form>
</Modal>
)
}

View File

@ -1,32 +0,0 @@
const THEME_KEY = 'tf-theme'
export function applyTheme(theme, persist = true) {
document.documentElement.setAttribute('data-theme', theme)
if (persist) {
try {
localStorage.setItem(THEME_KEY, theme)
} catch {
/* ignore */
}
}
}
export function getStoredTheme() {
try {
return localStorage.getItem(THEME_KEY)
} catch {
return null
}
}
export function initTheme() {
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
const saved = getStoredTheme()
applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false)
}
export function toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme')
applyTheme(cur === 'dark' ? 'light' : 'dark', true)
return document.documentElement.getAttribute('data-theme')
}

View File

@ -0,0 +1,97 @@
/* ============================================================
ThemeProvider restores the one theme behaviour the React app dropped.
js/app.js's rule: an explicit past choice wins; otherwise follow the OS AND
KEEP FOLLOWING IT until the user picks a side themselves. The existing
src/theme.js reads the stored value at boot but never subscribes to the media
query, so a system theme change mid-session did nothing.
Charts no longer need a full-view re-render on theme change <Chart/>
observes [data-theme] and redraws itself.
============================================================ */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
const KEY = 'tf-theme'
const ThemeContext = createContext(null)
export function getStoredTheme() {
try {
return localStorage.getItem(KEY)
} catch {
return null
}
}
export function applyTheme(theme, persist = true) {
document.documentElement.setAttribute('data-theme', theme)
if (persist) {
try {
localStorage.setItem(KEY, theme)
} catch {
/* ignore */
}
}
}
/** Run before first paint (from main.jsx) so there is no light-mode flash. */
export function initTheme() {
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
const saved = getStoredTheme()
applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>')
return ctx
}
export default function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(
() => document.documentElement.getAttribute('data-theme') || 'light',
)
useEffect(() => {
if (!window.matchMedia) return undefined
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onSystemChange = (e) => {
// Once the user has chosen explicitly, stop following the OS.
if (getStoredTheme()) return
const next = e.matches ? 'dark' : 'light'
applyTheme(next, false)
setThemeState(next)
}
mq.addEventListener('change', onSystemChange)
return () => mq.removeEventListener('change', onSystemChange)
}, [])
const setTheme = useCallback((next) => {
applyTheme(next, true)
setThemeState(next)
}, [])
const toggleTheme = useCallback(() => {
setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
}, [setTheme])
/** The Settings > Appearance "System" option: forget the explicit choice. */
const useSystemTheme = useCallback(() => {
try {
localStorage.removeItem(KEY)
} catch {
/* ignore */
}
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
const next = dark ? 'dark' : 'light'
applyTheme(next, false)
setThemeState(next)
}, [])
const value = useMemo(
() => ({ theme, setTheme, toggleTheme, useSystemTheme, isExplicit: Boolean(getStoredTheme()) }),
[theme, setTheme, toggleTheme, useSystemTheme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

76
frontend/src/ui/Chart.jsx Normal file
View File

@ -0,0 +1,76 @@
/* ============================================================
Chart.jsx the single wrapper over the retained canvas engine (ADR 0013 §2).
The engine is imperative and owns its canvas. This component's whole job is
to call it at the right times:
- on mount and whenever the payload changes
- when the element resizes (ResizeObserver) this replaces the TWO
redundant debounced window-resize listeners in js/app.js that re-rendered
the entire view, and it also fixes charts that mounted inside a hidden
tab pane at zero width and never drew
- when [data-theme] flips, because the engine reads --c1..--c8 and
--border/--text-3/--bg-elev live from CSS
TWO THINGS CALLERS MUST KNOW:
1. `data` and `options` must be referentially stable wrap them in useMemo,
or the effect re-runs and re-animates on every parent render.
2. `height` is rendered as an HTML ATTRIBUTE, which is what the engine's
setup() reads. Passing height through `style` silently leaves every
chart at the default.
============================================================ */
import { useEffect, useRef } from 'react'
import Charts from '../lib/charts'
export default function Chart({ type, data, options, height = 260, className = '' }) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
if (!canvas || typeof Charts[type] !== 'function') return undefined
const draw = () => {
if (canvas.isConnected && canvas.getBoundingClientRect().width > 0) {
Charts[type](canvas, data, options)
}
}
draw()
// Observe the parent: setup() writes canvas.style.height, and observing the
// canvas itself would feed that write straight back in as a resize.
let timer
const ro = new ResizeObserver(() => {
clearTimeout(timer)
timer = setTimeout(draw, 120)
})
ro.observe(canvas.parentElement ?? canvas)
const mo = new MutationObserver(draw)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
clearTimeout(timer)
ro.disconnect()
mo.disconnect()
// The engine assigns these directly rather than using addEventListener.
canvas.onmousemove = null
canvas.onmouseleave = null
}
}, [type, data, options, height])
return <canvas ref={ref} className={className} height={height} />
}
/** Was Charts.legend(), which returned an HTML string. */
export function ChartLegend({ items = [] }) {
return (
<div className="chart-legend">
{items.map((it) => (
<span className="legend-item" key={it.label}>
<span className="legend-dot" style={{ background: it.color }} />
{it.label}
</span>
))}
</div>
)
}

View File

@ -0,0 +1,163 @@
/* ============================================================
DataTable.jsx the js/ui.js dataTable, split into a headless hook and a
presentational component.
The split matters: Candidates needs the sort/paginate behaviour but renders
its own markup (a checkbox column bound to a selection Set), so it uses
useDataTable alone. The other six consumers use <DataTable/>.
Sort comparator and the ellipsis pager windowing are ported verbatim.
Client-side sort/paginate is retained deliberately there are no paginated
list endpoints to bind to yet outside /users/fetch.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import Icon from './icons'
import { EmptyState } from './primitives'
export function useDataTable({ columns, rows, pageSize = 10 }) {
const [sort, setSort] = useState({ key: null, dir: 1 })
const [page, setPage] = useState(1)
// The prototype reset to page 1 inside its imperative update(rows).
useEffect(() => setPage(1), [rows])
const sorted = useMemo(() => {
if (!sort.key) return rows
const col = columns.find((c) => c.key === sort.key)
return [...rows].sort((a, b) => {
let va = col?.sortValue ? col.sortValue(a) : a[sort.key]
let vb = col?.sortValue ? col.sortValue(b) : b[sort.key]
if (typeof va === 'string') {
va = va.toLowerCase()
vb = (vb || '').toLowerCase()
}
if (va < vb) return -1 * sort.dir
if (va > vb) return 1 * sort.dir
return 0
})
}, [rows, columns, sort])
const total = sorted.length
const pages = Math.max(1, Math.ceil(total / pageSize))
const current = Math.min(page, pages)
const start = (current - 1) * pageSize
function toggleSort(key) {
setSort((s) => (s.key === key ? { key, dir: s.dir * -1 } : { key, dir: 1 }))
}
return {
pageRows: sorted.slice(start, start + pageSize),
sort,
toggleSort,
page: current,
pages,
setPage,
from: total ? start + 1 : 0,
to: Math.min(start + pageSize, total),
total,
pageButtons: pageWindow(current, pages),
}
}
/** 1 … cur-1 cur cur+1 … n — the prototype's windowing, unchanged. */
function pageWindow(cur, pages) {
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('…')
}
return list
}
export function Pagination({ from, to, total, page, pages, setPage, pageButtons }) {
return (
<div className="pagination">
<span className="page-info">
Showing <b>{from}{to}</b> of <b>{total}</b>
</span>
<div className="page-controls">
<button className="page-btn" disabled={page === 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<Icon name="chevron-left" />
</button>
{pageButtons.map((p, i) =>
p === '…' ? (
<span key={`gap-${i}`} className="page-btn" style={{ cursor: 'default' }}></span>
) : (
<button
key={p}
className={`page-btn ${p === page ? 'active' : ''}`}
onClick={() => setPage(p)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
),
)}
<button className="page-btn" disabled={page === pages} onClick={() => setPage(page + 1)} aria-label="Next page">
<Icon name="chevron-right" />
</button>
</div>
</div>
)
}
export default function DataTable({ columns, rows, pageSize = 10, empty }) {
const t = useDataTable({ columns, rows, pageSize })
return (
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">
{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}
</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState>{empty}</EmptyState>
</td>
</tr>
) : (
t.pageRows.map((row, i) => (
<tr key={row.id ?? i}>
{columns.map((c) => (
<td key={c.key} style={{ textAlign: c.align || 'left' }}>
{c.render ? c.render(row) : (row[c.key] ?? '')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
)
}

View File

@ -0,0 +1,52 @@
/* ============================================================
Dropdown.jsx replaces the [data-dd-toggle]/[data-dd-panel] delegation in
js/app.js:200-215, including its "only one open at a time" behaviour.
============================================================ */
import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
const GroupContext = createContext(null)
/** Wrap sibling dropdowns so opening one closes the others. */
export function DropdownGroup({ children }) {
const [openId, setOpenId] = useState(null)
const value = useMemo(() => ({ openId, setOpenId }), [openId])
return <GroupContext.Provider value={value}>{children}</GroupContext.Provider>
}
export default function Dropdown({ trigger, children, className = '', panelClassName = '' }) {
const id = useId()
const group = useContext(GroupContext)
const [localOpen, setLocalOpen] = useState(false)
const ref = useRef(null)
const open = group ? group.openId === id : localOpen
const setOpen = (next) => {
if (group) group.setOpenId(next ? id : null)
else setLocalOpen(next)
}
useEffect(() => {
if (!open) return undefined
const onDocClick = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false)
}
const onKey = (e) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKey)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<div className={`dropdown ${open ? 'open' : ''} ${className}`} ref={ref}>
{trigger({ open, toggle: () => setOpen(!open) })}
<div className={`dropdown-menu ${panelClassName}`}>{children}</div>
</div>
)
}

104
frontend/src/ui/Modal.jsx Normal file
View File

@ -0,0 +1,104 @@
/* ============================================================
Modal.jsx the js/ui.js modal, as a portal.
Same markup and class names. Adds the two things the original lacked and that
01-repository-assessment.md §5 item 9 calls out: a focus trap, and focus
restored to whatever opened the modal. Closing the prototype's modal dropped
focus to <body>, which strands keyboard and screen-reader users.
Body scroll locking is reference-counted, because the mobile nav drawer also
sets body.overflow without the count, closing a modal while the drawer is
open would unlock scrolling underneath it.
============================================================ */
import { useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import Icon from './icons'
let scrollLocks = 0
function lockScroll() {
scrollLocks += 1
document.body.style.overflow = 'hidden'
}
function unlockScroll() {
scrollLocks = Math.max(0, scrollLocks - 1)
if (scrollLocks === 0) document.body.style.overflow = ''
}
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
export default function Modal({ open = true, title, subtitle, size, footer, onClose, children }) {
const dialogRef = useRef(null)
const restoreRef = useRef(null)
const close = useCallback(() => onClose?.(), [onClose])
useEffect(() => {
if (!open) return undefined
restoreRef.current = document.activeElement
lockScroll()
const node = dialogRef.current
const first = node?.querySelector(FOCUSABLE)
;(first ?? node)?.focus?.()
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab' || !node) return
const items = Array.from(node.querySelectorAll(FOCUSABLE)).filter(
(el) => el.offsetParent !== null,
)
if (!items.length) return
const firstEl = items[0]
const lastEl = items[items.length - 1]
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault()
lastEl.focus()
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault()
firstEl.focus()
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
unlockScroll()
restoreRef.current?.focus?.()
}
}, [open, close])
if (!open) return null
return createPortal(
<div className="modal-root open">
<div className="modal-backdrop" onClick={close} />
<div
className={`modal ${size || ''}`}
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : undefined}
tabIndex={-1}
ref={dialogRef}
>
<div className="modal-head">
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
<button className="modal-close" onClick={close} aria-label="Close">
<Icon name="x" />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>,
document.body,
)
}

49
frontend/src/ui/Tabs.jsx Normal file
View File

@ -0,0 +1,49 @@
/* ============================================================
Tabs.jsx new primitive.
js/ui.js had no tab component, so settings (10 tabs), candidates (8), inbox
(7) and rbac each hand-rolled one by pre-rendering every pane and toggling
`.active`. One component replaces four ad-hoc implementations, and only the
active pane is mounted which also means a chart in a hidden pane no longer
draws into a zero-width canvas.
============================================================ */
import { useId, useState } from 'react'
export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
const id = useId()
return (
<div className={className} role="tablist">
{tabs.map((t) => {
const key = t.key ?? t
const label = t.label ?? t
const active = key === value
return (
<button
key={key}
id={`${id}-${key}`}
role="tab"
aria-selected={active}
className={`tab${active ? ' active' : ''}`}
onClick={() => onChange(key)}
>
{label}
{t.count != null && <span className="tab-count">{t.count}</span>}
</button>
)
})}
</div>
)
}
/** Uncontrolled convenience wrapper: <TabPanel tabs={[{key,label,render}]} /> */
export default function TabPanel({ tabs, initial, className }) {
const [value, setValue] = useState(initial ?? tabs[0]?.key)
const active = tabs.find((t) => t.key === value) ?? tabs[0]
return (
<>
<Tabs tabs={tabs} value={value} onChange={setValue} className={className} />
<div role="tabpanel">{active?.render?.()}</div>
</>
)
}

103
frontend/src/ui/Toast.jsx Normal file
View File

@ -0,0 +1,103 @@
/* ============================================================
Toast.jsx the js/ui.js toast, as a provider + portal.
Timing is preserved exactly: 4200ms visible, then `.out` is added and the
node is removed 300ms later. The `.out` exit transition lives in the frozen
CSS, so collapsing this into a single removal makes toasts vanish instead of
sliding away.
============================================================ */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import Icon from './icons'
const VISIBLE_MS = 4200
const EXIT_MS = 300
const CONFIG = {
success: { icon: 'check-circle', tone: 'i-green', title: 'Success' },
error: { icon: 'x-circle', tone: 'i-red', title: 'Error' },
info: { icon: 'info', tone: 'i-blue', title: 'Notice' },
warning: { icon: 'alert', tone: 'i-amber', title: 'Warning' },
}
const ToastContext = createContext(null)
// Module-level escape hatch, so non-component code (the ~40 call sites that were
// `App.toast(...)`) can raise a toast without threading the hook through.
let externalToast = () => {}
export function toast(msg, type, title) {
externalToast(msg, type, title)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>')
return ctx
}
export default function ToastProvider({ children }) {
const [items, setItems] = useState([])
const timers = useRef(new Map())
const remove = useCallback((id) => {
setItems((cur) => cur.map((t) => (t.id === id ? { ...t, leaving: true } : t)))
const t = setTimeout(() => {
setItems((cur) => cur.filter((x) => x.id !== id))
timers.current.delete(id)
}, EXIT_MS)
timers.current.set(`exit-${id}`, t)
}, [])
const push = useCallback(
(msg, type = 'info', title) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
setItems((cur) => [...cur, { id, msg, type, title, leaving: false }])
timers.current.set(id, setTimeout(() => remove(id), VISIBLE_MS))
return id
},
[remove],
)
useEffect(() => {
externalToast = push
return () => {
externalToast = () => {}
}
}, [push])
useEffect(() => {
const map = timers.current
return () => map.forEach(clearTimeout)
}, [])
const value = useMemo(() => ({ toast: push, dismiss: remove }), [push, remove])
return (
<ToastContext.Provider value={value}>
{children}
{createPortal(
<div className="toast-root">
{items.map((t) => {
const cfg = CONFIG[t.type] || CONFIG.info
return (
<div key={t.id} className={`toast${t.leaving ? ' out' : ''}`}>
<span className={`toast-icn ${cfg.tone}`}>
<Icon name={cfg.icon} />
</span>
<div className="toast-body">
<div className="toast-title">{t.title || cfg.title}</div>
<div className="toast-msg">{t.msg}</div>
</div>
<button className="toast-close" onClick={() => remove(t.id)} aria-label="Dismiss">
<Icon name="x" />
</button>
</div>
)
})}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}

412
frontend/src/ui/icons.jsx Normal file
View File

@ -0,0 +1,412 @@
/* ============================================================
icons.jsx the prototype's inline SVG set, as JSX.
js/ui.js held these as raw markup strings and injected them with innerHTML.
Rendering strings would require dangerouslySetInnerHTML, which ADR 0013 bans
outright, so each entry is a fragment of SVG children instead. Every attribute
in the original set is single-word lowercase (d, points, cx, cy, r, x, y, x1,
y1, x2, y2, rx, width, height), all of which are valid React props unchanged
so this is a pure mechanical wrap with no attribute renaming.
Stroke styling lives in css/styles.css and is untouched.
============================================================ */
export const ICONS = {
'user-plus': (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</>
),
calendar: (
<>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</>
),
check: <polyline points="20 6 9 17 4 12" />,
'check-circle': (
<>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</>
),
x: (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
),
'x-circle': (
<>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</>
),
file: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</>
),
star: (
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
),
message: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />,
info: (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>
),
alert: (
<>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
eye: (
<>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</>
),
edit: (
<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" />
</>
),
trash: (
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</>
),
more: (
<>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</>
),
plus: (
<>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</>
),
download: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>
),
filter: <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />,
clock: (
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</>
),
mail: (
<>
<path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
<polyline points="22,6 12,13 2,6" />
</>
),
phone: (
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z" />
),
map: (
<>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</>
),
briefcase: (
<>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</>
),
'trending-up': (
<>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</>
),
'trending-down': (
<>
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
<polyline points="17 18 23 18 23 12" />
</>
),
users: (
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
award: (
<>
<circle cx="12" cy="8" r="7" />
<polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88" />
</>
),
dollar: (
<>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</>
),
target: (
<>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</>
),
send: (
<>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</>
),
video: (
<>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>
),
'chevron-left': <polyline points="15 18 9 12 15 6" />,
'chevron-right': <polyline points="9 18 15 12 9 6" />,
'chevron-down': <path d="M6 9l6 6 6-6" />,
refresh: (
<>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</>
),
copy: (
<>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
),
upload: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</>
),
linkedin: (
<>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</>
),
inbox: (
<>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</>
),
sparkles: (
<>
<path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z" />
<path d="M19 3v4M21 5h-4M5 17v4M7 19H3" />
</>
),
zap: <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />,
grid: (
<>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</>
),
bookmark: <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />,
paperclip: (
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
),
external: (
<>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</>
),
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />,
lock: (
<>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</>
),
flame: (
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
),
bell: (
<>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</>
),
layers: (
<>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</>
),
list: (
<>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</>
),
'check-square': (
<>
<polyline points="9 11 12 14 22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</>
),
'arrow-right': (
<>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</>
),
/* --- shell icons: these lived inline in index.html, not in js/ui.js --- */
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</>
),
pipeline: (
<>
<rect x="2" y="4" width="6" height="16" rx="1" />
<rect x="9" y="4" width="6" height="10" rx="1" />
<rect x="16" y="4" width="6" height="13" rx="1" />
</>
),
reports: (
<>
<path d="M3 3v18h18" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</>
),
analytics: (
<>
<path d="M21 21H3V3" />
<path d="M7 14l4-4 3 3 5-6" />
</>
),
offers: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M9 15l2 2 4-4" />
</>
),
managers: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>
),
help: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
menu: (
<>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
),
sun: (
<>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</>
),
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
user: (
<>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</>
),
logout: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<line x1="21" y1="12" x2="9" y2="12" />
</>
),
talent: (
<>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<path d="M22 4L12 14.01l-3-3" />
</>
),
}
export default function Icon({ name, className = '' }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
{ICONS[name] ?? ICONS.info}
</svg>
)
}

View File

@ -0,0 +1,124 @@
/* ============================================================
primitives.jsx Avatar, Badge, ScoreChip, ProgressBar, EmptyState, KpiCard.
Ported one-for-one from js/ui.js. Every class name is preserved verbatim so
css/styles.css keeps matching without a single selector change (ADR 0013 §3).
============================================================ */
import Icon from './icons'
import { avatarColor, initials as initialsOf } from '../data/seed'
export function Avatar({ name = '', initials, color, className = '' }) {
const bg = color || avatarColor(name || '')
const text = initials || initialsOf(name || '?')
return (
<span className={`avatar ${className}`} style={{ background: bg }}>
{text}
</span>
)
}
export function AvatarStack({ names = [], max = 3 }) {
const shown = names.slice(0, max)
const extra = names.length - max
return (
<div className="avatar-stack">
{shown.map((n, i) => (
<Avatar key={`${n}-${i}`} name={n} />
))}
{extra > 0 && <span className="more-count">+{extra}</span>}
</div>
)
}
// The 30-entry status -> class map from js/ui.js:73-81, verbatim.
export const STATUS_CLASS = {
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',
}
export function Badge({ children, className }) {
const cls = className || STATUS_CLASS[children] || 'b-gray'
return <span className={`badge ${cls}`}>{children}</span>
}
export 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 (
<span className="score">
<span className="score-ring" style={{ '--pct': score, '--sc-color': color }}>
<span style={{ color }}>{score}</span>
</span>
</span>
)
}
export function ProgressBar({ pct, className }) {
const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red'
return (
<div className="pbar">
<div className={`pbar-fill ${className || c}`} style={{ width: `${pct}%` }} />
</div>
)
}
export function EmptyState({ icon = 'search', title = 'No results found', children }) {
return (
<div className="empty-state">
<Icon name={icon} />
<h3>{title}</h3>
<p>{children || 'Try adjusting your filters or search.'}</p>
</div>
)
}
/** Trend chip: `dir` is 'up' | 'down' | 'flat', matching js/dashboard.js:21-26. */
export function Trend({ dir, children }) {
if (dir === 'flat') return <span className="trend trend-flat">{children}</span>
return (
<span className={`trend ${dir === 'up' ? 'trend-up' : 'trend-down'}`}>
<Icon name={dir === 'up' ? 'trending-up' : 'trending-down'} />
{children}
</span>
)
}
/**
* The KPI card repeated across dashboard (8), interviews, jobboard and
* recruiterhub. Markup is exactly js/dashboard.js:27-35 so the frozen CSS
* matches: .kpi > .kpi-top > (.kpi-label + .kpi-icn), .kpi-value, .kpi-foot.
*/
export function KpiCard({ icon, tone = 'i-indigo', label, value, foot, trend, dir = 'up' }) {
return (
<div className="kpi">
<div className="kpi-top">
<span className="kpi-label">{label}</span>
<span className={`kpi-icn ${tone}`}>
<Icon name={icon} />
</span>
</div>
<div className="kpi-value">{value}</div>
{(trend || foot) && (
<div className="kpi-foot">
{trend && <Trend dir={dir}>{trend}</Trend>}
{foot && <span className="kpi-foot-text">{foot}</span>}
</div>
)}
</div>
)
}
export function FieldError({ children }) {
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
}
export { Icon }

212
frontend/token.test.mjs Normal file
View File

@ -0,0 +1,212 @@
/**
* Token-layer test the headline behaviour of this migration.
*
* npm run test:token
*
* Covers, against a scripted fake server:
* 1. proactive renewal inside the 60s skew window
* 2. reactive 401 -> refresh -> retry, exactly once
* 3. SINGLE-FLIGHT: N concurrent 401s produce ONE /users/refresh
* 4. permissions survive a refresh (the response omits them)
* 5. a rejected refresh token clears the session and fires onSessionExpired
* 6. no infinite retry when the retry also 401s
*/
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'
const dom = new JSDOM('', { url: 'http://localhost:5173/' })
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.localStorage = dom.window.localStorage
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
const outDir = mkdtempSync(join(tmpdir(), 'tf-token-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
entryPoints: ['src/__smoke__/token.entry.js'],
outfile: outFile, bundle: true, format: 'esm', platform: 'node', target: 'node20',
logLevel: 'error',
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
})
const T = await import(pathToFileURL(outFile).href)
// ------------------------------------------------------------------ harness
let calls = []
let accessValid = 'A1'
let refreshValid = 'R1'
let refreshCount = 0
let rejectRefresh = false
let alwaysUnauthorized = false
function json(status, body) {
return {
ok: status >= 200 && status < 300,
status,
statusText: '',
// apiClient reads text(); refresh.js reads json(). Provide both.
text: async () => JSON.stringify(body),
json: async () => body,
}
}
globalThis.fetch = async (url, opts = {}) => {
// apiClient builds absolute URLs via `new URL(...)`; refresh.js uses a
// relative path. Normalise so the harness sees one shape.
const path = String(url).replace(/^https?:\/\/[^/]+/, '')
calls.push(path)
if (path === '/users/refresh') {
const sent = JSON.parse(opts.body).refresh_token
if (rejectRefresh || sent !== refreshValid) return json(401, { detail: 'Invalid or expired refresh token' })
refreshCount += 1
accessValid = `A${refreshCount + 1}`
refreshValid = `R${refreshCount + 1}` // the backend ROTATES both
// NOTE: no `permissions` in `data` — this is the real backend's shape.
return json(200, {
access_token: accessValid, refresh_token: refreshValid, token_type: 'bearer',
expires_in: 1800, data: { id: 1, name: 'Test User', email: 't@example.com' },
})
}
const bearer = (opts.headers?.Authorization || '').replace('Bearer ', '')
if (alwaysUnauthorized || bearer !== accessValid) return json(401, { detail: 'Could not validate credentials' })
return json(200, { data: { path }, status_code: 200 })
}
function reset({ expiresIn = 1800 } = {}) {
calls = []
refreshCount = 0
rejectRefresh = false
alwaysUnauthorized = false
accessValid = 'A1'
refreshValid = 'R1'
T.clearSession()
T.setSession({
access_token: 'A1', refresh_token: 'R1', expires_in: expiresIn,
data: { id: 1, name: 'Test User', permissions: ['jobs.view', 'candidates.view'] },
})
}
const results = []
function check(name, pass, detail = '') {
results.push({ name, pass, detail })
console.log(`${pass ? 'ok ' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`)
}
// ------------------------------------------------------------------ 1. proactive
{
reset({ expiresIn: 30 }) // already inside the 60s skew window
await T.request('/jobs/fetch')
const order = calls.join(' ')
check(
'proactive renewal fires BEFORE any 401',
calls[0] === '/users/refresh' && calls[1] === '/jobs/fetch' && refreshCount === 1,
`calls: ${order}`,
)
}
// ------------------------------------------------------------------ 2. reactive
{
reset() // token not near expiry…
accessValid = 'SOMETHING-ELSE' // …but the server rejects it anyway
await T.request('/jobs/fetch')
check(
'reactive 401 -> refresh -> retry (exactly one retry)',
calls.filter((c) => c === '/jobs/fetch').length === 2 && refreshCount === 1,
`calls: ${calls.join(' ')}`,
)
}
// ------------------------------------------------------------------ 3. SINGLE-FLIGHT
{
reset()
accessValid = 'SOMETHING-ELSE'
await Promise.all([
T.request('/jobs/fetch'), T.request('/candidates/fetch'), T.request('/roles/fetch'),
T.request('/users/fetch'), T.request('/permissions/fetch'), T.request('/inbox/fetch'),
])
const refreshes = calls.filter((c) => c === '/users/refresh').length
check(
'SINGLE-FLIGHT: 6 concurrent 401s produce exactly ONE /users/refresh',
refreshes === 1,
`saw ${refreshes} refresh call(s); the backend rotates the refresh token, so >1 orphans a pair`,
)
}
// ------------------------------------------------------------------ 3b. proactive single-flight
{
reset({ expiresIn: 10 })
await Promise.all([T.request('/a'), T.request('/b'), T.request('/c'), T.request('/d')])
const refreshes = calls.filter((c) => c === '/users/refresh').length
check('SINGLE-FLIGHT: 4 concurrent proactive renewals produce ONE refresh', refreshes === 1, `saw ${refreshes}`)
}
// ------------------------------------------------------------------ 4. permissions survive
{
reset({ expiresIn: 30 })
await T.request('/jobs/fetch')
const perms = T.getSession()?.data?.permissions
check(
'permissions survive a refresh (response omits them)',
Array.isArray(perms) && perms.includes('jobs.view'),
`permissions after refresh: ${JSON.stringify(perms)}`,
)
}
// ------------------------------------------------------------------ 4b. rotation stored
{
reset({ expiresIn: 30 })
await T.request('/jobs/fetch')
const s = T.getSession()
check(
'both tokens rotate and are persisted',
s.access_token === 'A2' && s.refresh_token === 'R2' && s.expires_at > Date.now(),
`access=${s.access_token} refresh=${s.refresh_token}`,
)
}
// ------------------------------------------------------------------ 5. expired refresh
{
reset()
accessValid = 'SOMETHING-ELSE'
rejectRefresh = true
let expiredFired = false
T.setSessionExpiredHandler(() => { expiredFired = true })
let threw = false
try { await T.request('/jobs/fetch') } catch { threw = true }
check(
'rejected refresh token -> session cleared + onSessionExpired fired',
threw && expiredFired && T.getSession() === null,
`threw=${threw} handlerFired=${expiredFired} session=${T.getSession()}`,
)
T.setSessionExpiredHandler(() => {})
}
// ------------------------------------------------------------------ 6. no retry loop
{
reset()
alwaysUnauthorized = true // refresh succeeds, but the resource still 401s
let expiredFired = false
T.setSessionExpiredHandler(() => { expiredFired = true })
let threw = false
try { await T.request('/jobs/fetch') } catch { threw = true }
const attempts = calls.filter((c) => c === '/jobs/fetch').length
check(
'no infinite loop when the retry also 401s (deactivated user)',
threw && expiredFired && attempts === 2,
`resource attempts=${attempts} (expected exactly 2)`,
)
T.setSessionExpiredHandler(() => {})
}
rmSync(outDir, { recursive: true, force: true })
const failed = results.filter((r) => !r.pass).length
console.log(failed ? `\n${failed}/${results.length} token checks FAILED` : `\nAll ${results.length} token checks passed`)
process.exit(failed ? 1 : 0)

View File

@ -4,12 +4,18 @@ import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
// One SPA at `/`. The auth screens keep /auth/* as *router* paths so the
// backend's CONFIRM_EMAIL_PATH=/auth/confirm-email links resolve unchanged.
//
// `vite dev` and `vite preview` both serve index.html for unmatched paths, which
// is what devserver.py's SPA_ROOTS hack used to fake — and they do it for every
// path, not just /auth/. A CDN/S3 deploy needs an equivalent rewrite rule.
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: '/auth/', base: '/',
build: { outDir: path.resolve(repoRoot, 'auth'), emptyOutDir: true }, build: { outDir: 'dist', emptyOutDir: true, sourcemap: true },
resolve: { alias: { '@shared-css': path.resolve(repoRoot, 'css/styles.css') } }, resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
server: { port: 5173, fs: { allow: [repoRoot] } }, server: { port: 5173 },
preview: { port: 4173 },
}) })

View File

@ -1,288 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
the safe-area insets in styles.css keep content clear of them.
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>TalentFlow · Applicant Tracking System</title>
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
compatible stand-in for Neue Montreal, which is a licensed face.
If Neue Montreal is installed locally it wins via the CSS stack. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="css/styles.css" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
</head>
<body>
<div id="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-logo">
<!-- Utopia Brands emblem — vector traced from the brand guideline.
The upright left edge is the abstract 'u'; the curve is the leaf. -->
<svg class="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z"/>
</svg>
</div>
<div class="brand-text">
<span class="brand-name">TalentFlow</span>
<span class="brand-sub">Utopia Brands · ATS</span>
</div>
<button class="sidebar-collapse-btn" id="sidebarCollapse" title="Collapse sidebar" aria-label="Collapse sidebar">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg>
</button>
</div>
<nav class="sidebar-nav" id="sidebarNav">
<div class="nav-section-label">Workspace</div>
<a class="nav-item" data-route="dashboard" href="#dashboard">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
<span>Dashboard</span>
</a>
<a class="nav-item" data-route="inbox" href="#inbox">
<svg viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
<span>Recruitment Inbox</span>
<span class="nav-badge nav-badge-alert" id="navInboxBadge">0</span>
</a>
<a class="nav-item" data-route="jobs" href="#jobs">
<svg viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<span>Jobs</span>
<span class="nav-badge" id="navJobsBadge">0</span>
</a>
<a class="nav-item" data-route="candidates" href="#candidates">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Candidates</span>
</a>
<a class="nav-item" data-route="talentpool" href="#talentpool">
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>
<span>Talent Pool</span>
</a>
<a class="nav-item" data-route="pipeline" href="#pipeline">
<svg viewBox="0 0 24 24"><rect x="2" y="4" width="6" height="16" rx="1"/><rect x="9" y="4" width="6" height="10" rx="1"/><rect x="16" y="4" width="6" height="13" rx="1"/></svg>
<span>Pipeline</span>
</a>
<div class="nav-section-label">Recruiting</div>
<a class="nav-item" data-route="import" href="#import">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>CV Import</span>
</a>
<a class="nav-item" data-route="jobboard" href="#jobboard">
<svg viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
<span>Job Board</span>
</a>
<a class="nav-item" data-route="recruiterhub" href="#recruiterhub">
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span>Recruiter Hub</span>
</a>
<a class="nav-item" data-route="tasks" href="#tasks">
<svg viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Tasks</span>
<span class="nav-badge" id="navTaskBadge">0</span>
</a>
<a class="nav-item" data-route="aiassistant" href="#aiassistant">
<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/></svg>
<span>AI Assistant</span>
<span class="nav-badge nav-badge-ai">AI</span>
</a>
<div class="nav-section-label">Hiring</div>
<a class="nav-item" data-route="interviews" href="#interviews">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Interviews</span>
</a>
<a class="nav-item" data-route="assessments" href="#assessments">
<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Assessments</span>
</a>
<a class="nav-item" data-route="offers" href="#offers">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 15l2 2 4-4"/></svg>
<span>Offers</span>
</a>
<a class="nav-item" data-route="managers" href="#managers">
<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
<span>Hiring Managers</span>
</a>
<a class="nav-item" data-route="calendar" href="#calendar">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Calendar</span>
</a>
<div class="nav-section-label">Insights</div>
<a class="nav-item" data-route="reports" href="#reports">
<svg viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
<span>Reports</span>
</a>
<a class="nav-item" data-route="analytics" href="#analytics">
<svg viewBox="0 0 24 24"><path d="M21 21H3V3"/><path d="M7 14l4-4 3 3 5-6"/></svg>
<span>Analytics</span>
</a>
<a class="nav-item" data-route="aistudio" href="#aistudio">
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
<span>AI Studio</span>
</a>
<a class="nav-item" data-route="notifications" href="#notifications">
<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>Notifications</span>
<span class="nav-badge nav-badge-alert" id="navNotifBadge">0</span>
</a>
<div class="nav-section-label">System</div>
<a class="nav-item" data-route="rbac" href="#rbac">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Access Control</span>
</a>
<a class="nav-item" data-route="settings" href="#settings">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Settings</span>
</a>
<a class="nav-item" data-route="help" href="#help">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>Help</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="usage-card">
<div class="usage-top"><span>Seats used</span><span id="usageCount">14 / 20</span></div>
<div class="usage-bar"><div class="usage-fill" style="width:70%"></div></div>
<button class="btn btn-ghost btn-block" onclick="App.toast('Upgrade flow coming soon','info')">Upgrade plan</button>
</div>
</div>
</aside>
<!-- Main -->
<div class="main-wrap">
<!-- Topbar -->
<header class="topbar">
<button class="icon-btn menu-toggle" id="mobileMenu" aria-label="Toggle menu">
<svg viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="globalSearch" placeholder="Search jobs, candidates, managers…" autocomplete="off" />
<div class="search-results" id="searchResults"></div>
<kbd class="search-kbd">⌘K</kbd>
</div>
<div class="topbar-actions">
<button class="icon-btn" id="themeToggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<div class="dropdown" id="messagesDropdown">
<button class="icon-btn" data-dd-toggle title="Messages" aria-label="Messages">
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="dot dot-blue"></span>
</button>
<div class="dropdown-menu dropdown-menu-wide" data-dd-panel>
<div class="dropdown-head">Messages</div>
<div id="messagesList"></div>
<div class="dropdown-foot"><a href="#notifications" data-route="notifications">Open inbox</a></div>
</div>
</div>
<div class="dropdown" id="notifDropdown">
<button class="icon-btn" data-dd-toggle title="Notifications" aria-label="Notifications">
<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="dot dot-red"></span>
</button>
<div class="dropdown-menu dropdown-menu-wide" data-dd-panel>
<div class="dropdown-head">Notifications <button class="link-btn" onclick="App.markAllNotifsRead()">Mark all read</button></div>
<div id="notifList"></div>
<div class="dropdown-foot"><a href="#notifications" data-route="notifications">View all</a></div>
</div>
</div>
<div class="topbar-divider"></div>
<div class="dropdown" id="profileDropdown">
<button class="profile-btn" data-dd-toggle>
<span class="avatar avatar-grad">AA</span>
<span class="profile-meta">
<span class="profile-name">Asfand Ahmed</span>
<span class="profile-role">Talent Lead</span>
</span>
<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="dropdown-menu" data-dd-panel>
<div class="dropdown-profile">
<span class="avatar avatar-grad avatar-lg">AA</span>
<div>
<div class="dp-name">Asfand Ahmed</div>
<div class="dp-email">asfand.ahmed@utopiabrands.com</div>
</div>
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>My Profile</a>
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
<a class="dropdown-link" href="#help" data-route="help"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Help Center</a>
<div class="dropdown-divider"></div>
<button class="dropdown-link danger" onclick="App.signOut()"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Sign out</button>
</div>
</div>
</div>
</header>
<!-- Content -->
<main class="content" id="main-content"></main>
</div>
</div>
<!-- AI Assistant floating launcher -->
<button class="ai-fab" id="aiFab" title="AI Recruiter Assistant" aria-label="Open AI Assistant">
<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/></svg>
</button>
<!-- AI Assistant slide-over dock -->
<div class="ai-dock" id="aiDock">
<div class="ai-dock-inner" id="aiDockInner"></div>
</div>
<!-- Modal root -->
<div class="modal-root" id="modalRoot"></div>
<!-- Toast root -->
<div class="toast-root" id="toastRoot"></div>
<!-- Mobile overlay -->
<div class="scrim" id="scrim"></div>
<script src="js/data.js"></script>
<script src="js/charts.js"></script>
<script src="js/ui.js"></script>
<script src="js/api.js"></script>
<script src="js/dashboard.js"></script>
<script src="js/jobs.js"></script>
<script src="js/candidates.js"></script>
<script src="js/pipeline.js"></script>
<script src="js/interviews.js"></script>
<script src="js/assessments.js"></script>
<script src="js/offers.js"></script>
<script src="js/reports.js"></script>
<script src="js/analytics.js"></script>
<script src="js/settings.js"></script>
<script src="js/misc.js"></script>
<script src="js/inbox.js"></script>
<script src="js/import.js"></script>
<script src="js/jobboard.js"></script>
<script src="js/recruiterhub.js"></script>
<script src="js/aiassistant.js"></script>
<script src="js/rbac.js"></script>
<script src="js/tasks.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@ -1,218 +0,0 @@
/* ============================================================
aiassistant.js ChatGPT-style AI Recruiter Assistant (UI only)
+ AI Studio (future modules gallery). API-ready, no AI wired.
============================================================ */
window.Views = window.Views || {};
window.AI = {};
// simulated (non-AI) canned responses keyed by intent — clearly a UI stub
AI._reply = function (prompt) {
const p = prompt.toLowerCase();
if (p.includes('rank')) {
const top = [...DB.candidates].sort((a, b) => b.aiScore - a.aiScore).slice(0, 5);
return `<p>Here are the top-ranked candidates by ATS match score:</p><ul>${top.map((c, i) => `<li><b>${i + 1}. ${c.name}</b> — ${c.aiScore}% match · ${c.jobTitle} · ${c.recommendation}</li>`).join('')}</ul><p class="text-muted">This is a UI preview. Connect an LLM endpoint to generate live rankings from resume + JD embeddings.</p>`;
}
if (p.includes('compare')) {
const two = DB.candidates.slice(0, 2);
return `<p>Comparing <b>${two[0].name}</b> vs <b>${two[1].name}</b>:</p><ul><li><b>Experience:</b> ${two[0].experience}y vs ${two[1].experience}y</li><li><b>ATS Score:</b> ${two[0].aiScore}% vs ${two[1].aiScore}%</li><li><b>Recommendation:</b> ${two[0].recommendation} vs ${two[1].recommendation}</li></ul><p><b>Suggested:</b> ${two[0].aiScore >= two[1].aiScore ? two[0].name : two[1].name} appears stronger on core criteria.</p>`;
}
if (p.includes('job description') || p.includes('jd')) {
return `<p><b>Senior Product Designer</b></p><p>We're looking for a Senior Product Designer to craft intuitive, delightful experiences across our platform. You'll own end-to-end design, from research to polished UI, and partner closely with product and engineering.</p><p><b>Responsibilities:</b> lead design for key initiatives, run user research, build and maintain design systems, mentor peers.</p><p><b>Requirements:</b> 5+ years product design, strong portfolio, fluency in Figma, systems thinking.</p>`;
}
if (p.includes('interview question')) {
return `<p>Here are role-specific interview questions:</p><ul><li>Walk me through how you'd design a system to handle 1M concurrent users.</li><li>Describe a technically challenging project and the tradeoffs you made.</li><li>How do you approach debugging a production incident under time pressure?</li><li>Tell me about a time you disagreed with a teammate on an approach.</li></ul>`;
}
if (p.includes('summar')) {
const c = DB.candidates[0];
return `<p><b>Resume summary — ${c.name}</b></p><p>${c.experience} years of experience, currently ${c.currentTitle} at ${c.currentCompany}. Strong in ${c.skills.slice(0, 3).join(', ')}. ATS match ${c.aiScore}% for ${c.jobTitle}. ${c.recommendation}.</p>`;
}
if (p.includes('email')) {
return `<p><b>Subject:</b> Interview Invitation — Next Steps</p><p>Hi [Candidate],</p><p>Thank you for applying. We were impressed by your background and would love to invite you to an interview. Please share your availability for this week.</p><p>Best regards,<br>Talent Team</p>`;
}
if (p.includes('offer letter')) {
return `<p><b>Offer Letter</b></p><p>Dear [Candidate], We are pleased to offer you the position of Product Manager at a base salary of $160,000, plus equity and benefits. This offer is contingent on standard background checks.</p><p>We're excited about the possibility of you joining the team.</p>`;
}
if (p.includes('skill gap')) {
return `<p><b>Skill Gap Analysis — Engineering pipeline</b></p><ul><li><span class="skill-pill skill-missing">Kubernetes</span> under-represented (only 22% of pipeline)</li><li><span class="skill-pill skill-missing">System Design</span> gap at senior level</li><li><span class="skill-pill skill-matched">React</span> well covered</li></ul><p>Consider sourcing candidates with cloud-native infra experience.</p>`;
}
if (p.includes('pipeline')) {
const total = DB.candidates.length;
return `<p><b>Pipeline health analysis</b></p><ul><li>${total} active candidates across 6 stages</li><li>Conversion Applied → Interview: ~28%</li><li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li><li>Offer acceptance trending at 82%</li></ul><p>Recommendation: accelerate assessment turnaround to improve velocity.</p>`;
}
if (p.includes('productivity') || p.includes('recruiter')) {
return `<p><b>Team productivity this month</b></p><ul><li>Top performer: ${[...DB.recruiters].sort((a, b) => b.hires - a.hires)[0].name}</li><li>Avg time-to-hire: 27 days (3 days faster than last month)</li><li>Interview completion rate: 91%</li></ul>`;
}
if (p.includes('recommend') || p.includes('suggest')) {
const c = [...DB.candidates].sort((a, b) => b.aiScore - a.aiScore)[0];
return `<p><b>Top recommendation:</b> ${c.name} (${c.aiScore}% match) for ${c.jobTitle}. Strong on ${c.matchedSkills.slice(0, 2).join(' & ')}. I'd prioritise scheduling a screen this week.</p>`;
}
return `<p>I can help with ranking candidates, comparing profiles, drafting JDs, interview questions, emails, offer letters, skill-gap and pipeline analysis, and more.</p><p class="text-muted">This is a fully-designed interface. Wire an AI endpoint (Claude / OpenAI) into <code>AI.send()</code> to make responses live.</p>`;
};
AI._chatHtml = function (compact) {
const promptsHtml = DB.aiPrompts.slice(0, compact ? 6 : 12).map(p =>
`<button class="prompt-chip" onclick="AI.usePrompt(this, ${JSON.stringify(p.prompt).replace(/"/g, '&quot;')})">${UI.icon(p.icon)} ${p.text}</button>`).join('');
return `
<div class="chat-wrap" ${compact ? 'style="height:100%"' : ''}>
<div class="chat-scroll" id="chatScroll">
<div class="ai-hero">
<div class="ai-logo">${UI.icon('sparkles')}</div>
<h2 style="font-size:${compact ? '18' : '22'}px;margin-bottom:6px">AI Recruiter Assistant</h2>
<p class="text-muted">Ask anything about your candidates, jobs, and pipeline</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center;max-width:720px;margin:0 auto 10px">${promptsHtml}</div>
</div>
<div style="padding-top:12px">
<div class="chat-input-bar">
<textarea id="chatInput" rows="1" placeholder="Message AI Assistant…"></textarea>
<button class="chat-send" id="chatSend">${UI.icon('arrow-right')}</button>
</div>
<p class="text-muted text-sm" style="text-align:center;margin-top:8px">UI preview · responses are simulated. ${UI.icon('lock')} API-ready for backend integration.</p>
</div>
</div>`;
};
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', `
<div class="chat-msg"><div class="chat-av user">${UI.icon('users')}</div>
<div class="chat-bubble"><div class="chat-role">You</div><div class="chat-text">${text.replace(/</g, '&lt;')}</div></div></div>`);
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', `
<div class="chat-msg" id="${typingId}"><div class="chat-av ai">${UI.icon('sparkles')}</div>
<div class="chat-bubble"><div class="chat-role">AI Assistant</div><div class="chat-typing"><span></span><span></span><span></span></div></div></div>`);
scroll.scrollTop = scroll.scrollHeight;
setTimeout(() => {
const t = document.getElementById(typingId);
if (t) t.querySelector('.chat-bubble').innerHTML = `<div class="chat-role">AI Assistant</div><div class="chat-text">${AI._reply(text)}</div>`;
scroll.scrollTop = scroll.scrollHeight;
}, 850 + Math.random() * 500);
};
// ---------------- Full page ----------------
Views.aiassistant = function () {
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">AI Assistant</h1><p class="page-sub">Your recruiting copilot powered by AI (interface preview)</p></div>
<div class="page-head-actions">
<span class="integration-status pending"><span class="pulse"></span>Model endpoint · Not connected</span>
<button class="btn btn-secondary" onclick="AI.newChat()">${UI.icon('plus')} New Chat</button>
</div>
</div>
<div class="card"><div class="card-body" id="aiChatMount">${AI._chatHtml(false)}</div></div>
</div>`;
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 = `
<div class="card-head" style="border-radius:0"><div><h3>${UI.icon('sparkles')} AI Assistant</h3></div>
<div class="flex items-center gap-8">
<button class="btn btn-ghost btn-sm" onclick="Router.go('aiassistant');AI._dockClose()">Expand</button>
<button class="modal-close" onclick="AI._dockClose()">${UI.icon('x')}</button></div></div>
<div style="flex:1;padding:16px;overflow:hidden;display:flex" id="dockChatMount">${AI._chatHtml(true)}</div>`;
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 => `
<div class="card" style="cursor:pointer" onclick="AI.moduleDetail('${m.name.replace(/'/g, "\\'")}')">
<div class="card-body">
<div class="flex items-center" style="justify-content:space-between;margin-bottom:12px">
<span class="kpi-icn ${m.cls}" style="width:46px;height:46px;border-radius:13px">${UI.icon(m.icon)}</span>
${UI.badge(m.status, m.status === 'Beta' ? 'b-indigo' : 'b-gray')}
</div>
<div class="lr-title" style="font-size:15px">${m.name}</div>
<div class="lr-sub" style="margin-top:5px;line-height:1.5">${m.desc}</div>
<div style="margin-top:14px;color:var(--primary);font-weight:600;font-size:13px">${m.status === 'Beta' ? 'Try it' : 'Join waitlist'} ${UI.icon('arrow-right')}</div>
</div>
</div>`).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">AI Studio</h1><p class="page-sub">Next-generation AI modules designed and API-ready for backend integration</p></div>
<div class="page-head-actions"><span class="integration-status pending"><span class="pulse"></span>${DB.aiModules.filter(m => m.status === 'Beta').length} in Beta</span>
<button class="btn btn-primary" onclick="AI._dockOpen(true)">${UI.icon('sparkles')} Open Assistant</button></div>
</div>
<div class="card brand-hero mb-18">
<div class="card-body" style="display:flex;align-items:center;gap:20px;flex-wrap:wrap">
<div class="ai-logo" style="margin:0;width:56px;height:56px">${UI.icon('sparkles')}</div>
<div style="flex:1;min-width:220px"><h2 style="font-size:19px;margin-bottom:4px">Everything is API-ready</h2>
<p style="opacity:.88">Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them no UI work required.</p></div>
<button class="btn btn-on-brand" onclick="UI.toast('Integration guide opened','info')">${UI.icon('external')} Integration Guide</button>
</div>
</div>
<div class="grid g-3">${cards}</div>
</div>`;
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: `<div class="flex items-center gap-16" style="margin-bottom:18px"><span class="kpi-icn ${m.cls}" style="width:56px;height:56px;border-radius:16px">${UI.icon(m.icon)}</span>
<div><div class="fw-600" style="font-size:16px">${m.name}</div><div class="text-muted">${m.desc}</div></div></div>
<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
<div class="form-section-title" style="margin-top:0">API Contract (preview)</div>
<div class="resume-thumb" style="max-height:none">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 }
}</div>
</div></div>
<p class="text-muted text-sm" style="margin-top:14px">${UI.icon('lock')} This feature's UI is complete. Backend wiring is the only remaining step.</p>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
<button class="btn btn-primary" onclick="UI.closeModal();AI._dockOpen(true)">${UI.icon('sparkles')} Try in Assistant</button>`,
size: 'modal-lg'
});
};

View File

@ -1,114 +0,0 @@
/* ============================================================
analytics.js Analytics dashboard (many charts)
============================================================ */
window.Views = window.Views || {};
Views.analytics = function () {
const a = DB.analytics;
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Analytics</h1><p class="page-sub">Deep-dive metrics across your recruitment funnel</p></div>
<div class="page-head-actions">
<div class="pill-tabs"><span class="pill-tab">Week</span><span class="pill-tab active">Month</span><span class="pill-tab">Quarter</span></div>
<button class="btn btn-secondary" onclick="UI.toast('Analytics exported','success')">${UI.icon('download')} Export</button>
</div>
</div>
<div class="grid g-2 mb-18">
<div class="card">
<div class="card-head"><div><h3>Hiring Trend</h3><span class="ch-sub">Hires vs applications</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anTrend" height="260"></canvas></div>
${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}</div>
</div>
<div class="card">
<div class="card-head"><div><h3>Applications Received</h3><span class="ch-sub">Monthly volume</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anApps" height="260"></canvas></div></div>
</div>
</div>
<div class="grid g-3 mb-18">
<div class="card">
<div class="card-head"><div><h3>Source Breakdown</h3></div></div>
<div class="card-body">
<div class="chart-wrap"><canvas id="anSource" height="220"></canvas></div>
<div class="chart-legend" id="anSourceLegend"></div>
</div>
</div>
<div class="card">
<div class="card-head"><div><h3>Offer Acceptance</h3></div></div>
<div class="card-body">
<div class="chart-wrap"><canvas id="anOffer" height="220"></canvas></div>
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot" style="background:var(--success)"></span>Accepted</span>
<span class="legend-item"><span class="legend-dot" style="background:var(--warning)"></span>Pending</span>
<span class="legend-item"><span class="legend-dot" style="background:var(--danger)"></span>Declined</span>
</div>
</div>
</div>
<div class="card">
<div class="card-head"><div><h3>Pipeline Distribution</h3></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anPipeline" height="260"></canvas></div></div>
</div>
</div>
<div class="grid g-2 mb-18">
<div class="card">
<div class="card-head"><div><h3>Applications by Department</h3><span class="ch-sub">Volume per team</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anDept" height="300"></canvas></div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Recruiter Performance</h3><span class="ch-sub">Hires by recruiter (top 8)</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anRec" height="300"></canvas></div></div>
</div>
</div>
<div class="grid g-2">
<div class="card">
<div class="card-head"><div><h3>Time to Hire</h3><span class="ch-sub">Days, monthly average</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anTTH" height="240"></canvas></div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Time to Fill</h3><span class="ch-sub">Days, monthly average</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="anTTF" height="240"></canvas></div></div>
</div>
</div>
</div>`;
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) =>
`<span class="legend-item"><span class="legend-dot" style="background:${Charts.PALETTE[i % Charts.PALETTE.length]}"></span>${s.source}</span>`).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] }] });
}
};
};

View File

@ -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;
}
};

317
js/app.js
View File

@ -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 <html> 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 => `
<div class="notif-row ${n.unread ? 'unread' : ''}">
<span class="notif-icn ${n.color}">${UI.icon(n.icon)}</span>
<div class="notif-body"><div class="notif-title">${n.title}</div><div class="notif-text">${n.text}</div><div class="notif-time">${n.time}</div></div>
</div>`).join('');
};
App.renderMessages = function () {
const list = document.getElementById('messagesList');
list.className = 'dd-scroll';
list.innerHTML = DB.messages.map(m => `
<div class="notif-row ${m.unread ? 'unread' : ''}">
${UI.avatar(m.name, m.initials, m.color)}
<div class="notif-body"><div class="notif-title">${m.name}</div><div class="notif-text">${m.text}</div><div class="notif-time">${m.time} ago</div></div>
</div>`).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 += `<div class="search-group-label">Jobs</div>` + jobs.map(j =>
`<div class="search-item" onclick="App.searchGo('jobs',()=>Jobs.view('${j.id}'))"><span class="kpi-icn i-indigo" style="width:32px;height:32px;border-radius:8px">${UI.icon('briefcase')}</span><div><div class="si-title">${j.title}</div><div class="si-sub">${j.id} · ${j.department}</div></div></div>`).join('');
if (cands.length) html += `<div class="search-group-label">Candidates</div>` + cands.map(c =>
`<div class="search-item" onclick="App.searchGo('candidates',()=>Candidates.openProfile('${c.id}'))">${UI.avatar(c.name, c.initials, c.color)}<div><div class="si-title">${c.name}</div><div class="si-sub">${c.jobTitle}</div></div></div>`).join('');
if (mgrs.length) html += `<div class="search-group-label">Hiring Managers</div>` + mgrs.map(m =>
`<div class="search-item" onclick="App.searchGo('managers',()=>Views._mgrDetail('${m.id}'))">${UI.avatar(m.name, m.initials, m.color)}<div><div class="si-title">${m.name}</div><div class="si-sub">${m.title}</div></div></div>`).join('');
if (!html) html = `<div class="search-empty">No results for "${q}"</div>`;
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 <html> 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);

View File

@ -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 => `<div class="user-cell">${UI.avatar(a.candidate, a.initials, a.color)}<div><div class="cell-primary">${a.candidate}</div><div class="cell-sub">${a.jobTitle}</div></div></div>` },
{ key: 'type', label: 'Assessment', sortable: true, render: a => `<div class="cell-primary text-sm">${a.type}</div><div class="cell-sub">${a.duration}</div>` },
{ key: 'assigned', label: 'Assigned', sortable: true, sortValue: a => a.assigned.getTime(), render: a => `<span class="text-muted">${DB.fmtShort(a.assigned)}</span>` },
{ key: 'due', label: 'Due', sortable: true, sortValue: a => a.due.getTime(), render: a => `<span class="text-muted">${DB.fmtShort(a.due)}</span>` },
{ key: 'score', label: 'Score', sortable: true, align: 'center', render: a => a.score !== null ? UI.scoreChip(a.score) : '<span class="text-muted">—</span>' },
{ key: 'status', label: 'Status', sortable: true, render: a => UI.badge(a.status) },
{ key: '_a', label: 'Actions', align: 'right', render: a => `
<div class="row-actions">
<button class="act-btn" data-tip="View" onclick="Assessments.view('${a.id}')">${UI.icon('eye')}</button>
<button class="act-btn" data-tip="Remind" onclick="UI.toast('Reminder sent to ${a.candidate}','info')">${UI.icon('mail')}</button>
</div>` }
]
});
const statusOpts = ['<option value="">All Status</option>'].concat(['Completed', 'In Progress', 'Pending', 'Expired'].map(s => `<option>${s}</option>`)).join('');
const typeOpts = ['<option value="">All Types</option>'].concat([...new Set(DB.assessments.map(a => a.type))].map(t => `<option>${t}</option>`)).join('');
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Assessments</h1><p class="page-sub">Coding tests, take-homes, and evaluations</p></div>
<div class="page-head-actions"><button class="btn btn-primary" onclick="Assessments.assign()">${UI.icon('plus')} Assign Assessment</button></div>
</div>
<div class="grid g-kpi mb-18">
${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')}
</div>
<div class="card">
<div class="card-body" style="padding-bottom:0">
<div class="toolbar">
<div class="toolbar-search">${UI.icon('search')}<input id="asSearch" placeholder="Search candidate or assessment…"/></div>
<select class="select" id="asStatus">${statusOpts}</select>
<select class="select" id="asType">${typeOpts}</select>
</div>
</div>
${table.html}
</div>
</div>`;
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 = `
<div class="flex items-center gap-12 mb-18">${UI.avatar(a.candidate, a.initials, a.color, 'avatar-lg')}
<div><div class="ph-name" style="font-size:17px">${a.candidate}</div><div class="ph-role">${a.type} · ${a.jobTitle}</div></div>
<div style="margin-left:auto">${UI.badge(a.status)}</div></div>
<div class="info-grid mb-18">
<div class="info-item"><div class="il">Type</div><div class="iv">${a.type}</div></div>
<div class="info-item"><div class="il">Duration</div><div class="iv">${a.duration}</div></div>
<div class="info-item"><div class="il">Assigned</div><div class="iv">${DB.fmtDate(a.assigned)}</div></div>
<div class="info-item"><div class="il">Due</div><div class="iv">${DB.fmtDate(a.due)}</div></div>
</div>
${a.score !== null ? `
<div class="divider"></div>
<div style="text-align:center;padding:10px 0">
<div style="font-size:44px;font-weight:800;letter-spacing:-1px;color:${a.score >= 70 ? 'var(--success)' : 'var(--warning)'}">${a.score}%</div>
<div class="text-muted">Overall Score</div>
</div>
<div class="mb-18">${UI.pbar(a.score)}</div>
<div class="form-section-title" style="margin-top:0">Section Breakdown</div>
${['Problem Solving', 'Code Quality', 'Communication', 'Time Management'].map(sec => {
const sc = DB.int(60, 98);
return `<div class="flex items-center gap-12" style="margin-bottom:10px"><span style="width:130px;font-size:13px">${sec}</span><div style="flex:1">${UI.pbar(sc)}</div><b style="width:40px;text-align:right">${sc}%</b></div>`;
}).join('')}` : `<div class="empty-state">${UI.icon('clock')}<h3>Assessment not completed</h3><p>Results will appear once the candidate submits.</p></div>`}`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
<button class="btn btn-primary" onclick="UI.closeModal();Candidates.openProfile('${a.candidateId}')">View Candidate</button>`;
UI.modal({ title: 'Assessment Result', subtitle: a.id, body, footer });
};
Assessments.assign = function () {
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
const body = `<form><div class="form-grid">
<div class="form-field col-span-2"><label>Candidate</label><select>${opt(DB.candidates.slice(0, 40).map(c => c.name))}</select></div>
<div class="form-field"><label>Assessment Type</label><select>${opt([...new Set(DB.assessments.map(a => a.type))])}</select></div>
<div class="form-field"><label>Time Limit</label><select><option>45 min</option><option>60 min</option><option>90 min</option><option>3 days</option></select></div>
<div class="form-field col-span-2"><label>Due Date</label><input type="date"/></div>
</div></form>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Assessment assigned & invite sent','success')">${UI.icon('send')} Assign</button>`;
UI.modal({ title: 'Assign Assessment', subtitle: 'Send an evaluation to a candidate', body, footer });
};

View File

@ -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 => `<span class="checkbox ${selected.has(c.id) ? 'on' : ''}" data-sel="${c.id}">${UI.icon('check')}</span>` },
{ key: 'name', label: 'Candidate', sortable: true, render: c => `<div class="user-cell">${UI.avatar(c.name, c.initials, c.color)}<div><div class="cell-primary">${c.name} ${c.favorite ? '<span class="star-btn on" style="display:inline">' + UI.icon('star') + '</span>' : ''}</div><div class="cell-sub">${c.currentTitle} · ${c.location}</div></div></div>` },
{ key: 'jobTitle', label: 'Applied Job', sortable: true, render: c => `<div class="text-sm">${c.jobTitle}</div><div class="cell-sub">${c.department}</div>` },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center', render: c => `<b>${c.experience}</b>y` },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: c => relevance(c), render: c => `<span class="badge ${DB.atsRecommendationClass(c.recommendation)} badge-plain">${relevance(c)}%</span>` },
{ key: 'stage', label: 'Stage', sortable: true, render: c => UI.badge(c.stage) },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center', render: c => `<span style="cursor:pointer" onclick="Candidates.atsMatch('${c.id}')">${UI.scoreChip(c.aiScore)}</span>` },
{ key: 'availability', label: 'Availability', render: c => `<span class="text-sm">${c.availability}</span><div class="cell-sub">${c.noticePeriod} notice</div>` },
{ key: '_a', label: 'Actions', align: 'right', render: c => `
<div class="row-actions">
<button class="act-btn star-btn ${c.favorite ? 'on' : ''}" data-tip="Favorite" data-fav="${c.id}">${UI.icon('star')}</button>
<button class="act-btn" data-tip="ATS Match" onclick="Candidates.atsMatch('${c.id}')">${UI.icon('target')}</button>
<button class="act-btn" data-tip="Profile" onclick="Candidates.openProfile('${c.id}')">${UI.icon('eye')}</button>
<button class="act-btn" data-tip="Advance" onclick="Candidates.advance('${c.id}')">${UI.icon('check')}</button>
</div>` }
],
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) => [`<option value="">${label}</option>`].concat(arr.map(o => `<option>${o}</option>`)).join('');
const jobTitles = [...new Set(DB.candidates.map(c => c.jobTitle))];
const filterPanel = `
<div class="filter-panel" id="filterPanel" style="display:none;padding:16px 0;border-top:1px solid var(--border);margin-top:12px">
<div class="form-field"><label>Job</label><select data-f="job">${optList(jobTitles, 'Any Job')}</select></div>
<div class="form-field"><label>Skill</label><select data-f="skill">${optList(DB.skillsPool, 'Any Skill')}</select></div>
<div class="form-field"><label>Department</label><select data-f="dept">${optList(DB.departments, 'Any Dept')}</select></div>
<div class="form-field"><label>Location</label><select data-f="location">${optList(DB.locations, 'Any Location')}</select></div>
<div class="form-field"><label>Experience</label><select data-f="exp">${optList(['0-2', '3-5', '6-9', '10+'], 'Any Exp')}</select></div>
<div class="form-field"><label>Education</label><select data-f="edu">${optList(DB.educationLevels, 'Any')}</select></div>
<div class="form-field"><label>Recruiter</label><select data-f="recruiter">${optList(DB.recruiters.map(r => r.name), 'Any Recruiter')}</select></div>
<div class="form-field"><label>Hiring Manager</label><select data-f="manager">${optList(DB.managers.map(m => m.name), 'Any Manager')}</select></div>
<div class="form-field"><label>Source</label><select data-f="source">${optList(DB.sources, 'Any Source')}</select></div>
<div class="form-field"><label>ATS Score</label><select data-f="ats">${optList(['85+', '70-84', '<70'], 'Any Score')}</select></div>
<div class="form-field"><label>Pipeline Stage</label><select data-f="stage">${optList(DB.stages, 'Any Stage')}</select></div>
<div class="form-field"><label>Interview Status</label><select data-f="interview">${optList(['Not Scheduled', 'Scheduled', 'Completed'], 'Any')}</select></div>
<div class="form-field"><label>Notice Period</label><select data-f="notice">${optList(['Immediate', '2 weeks', '1 month', '2 months', '3 months'], 'Any')}</select></div>
<div class="form-field"><label>Availability</label><select data-f="availability">${optList(['Immediate', '2 weeks', '1 month', 'Passive'], 'Any')}</select></div>
</div>`;
// recently viewed strip
const rv = DB.recentlyViewed.slice(0, 6).map(id => DB.getCandidate(id)).filter(Boolean);
const rvHtml = rv.length ? `<div class="flex items-center gap-8" style="margin-bottom:14px;flex-wrap:wrap">
<span class="text-muted text-sm fw-600">Recently viewed:</span>
${rv.map(c => `<button class="prompt-chip" style="padding:5px 10px" onclick="Candidates.openProfile('${c.id}')">${UI.avatar(c.name, c.initials, c.color)} ${c.name.split(' ')[0]}</button>`).join('')}
</div>` : '';
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Candidates</h1><p class="page-sub"><span id="canResultCount">${DB.candidates.length} candidates</span> · ranked by AI relevance</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="UI.toast('Search saved','success')">${UI.icon('bookmark')} Save Search</button>
<button class="btn btn-secondary" onclick="UI.toast('Candidates exported','success')">${UI.icon('download')} Export</button>
<button class="btn btn-primary" onclick="Candidates.openAdd()">${UI.icon('plus')} Add Candidate</button>
</div>
</div>
${rvHtml}
<div class="bulk-bar" id="bulkBar" style="display:none">
<span class="checkbox on">${UI.icon('check')}</span>
<span class="fw-600" id="bulkCount">0 selected</span>
<div style="flex:1"></div>
<button class="btn btn-sm" onclick="Candidates.bulk('email')">${UI.icon('mail')} Bulk Email</button>
<button class="btn btn-sm" onclick="Candidates.bulk('assign')">${UI.icon('users')} Assign</button>
<button class="btn btn-sm" onclick="Candidates.bulk('advance')">${UI.icon('check')} Advance</button>
<button class="btn btn-sm" onclick="Candidates.bulk('reject')">${UI.icon('x')} Reject</button>
<button class="btn btn-sm" onclick="Candidates.bulkClear()">${UI.icon('x')} Clear</button>
</div>
<div class="card">
<div class="card-body" style="padding-bottom:0">
<div class="toolbar">
<div class="toolbar-search">${UI.icon('search')}<input id="canSearch" placeholder="Search name, skill, company…"/></div>
<button class="btn btn-secondary" id="filterToggle">${UI.icon('filter')} Filters</button>
<div class="spacer"></div>
<label class="text-muted text-sm">Sort:</label>
<select class="select" id="canSort">
<option value="relevance">AI Relevance</option>
<option value="ats">ATS Score</option>
<option value="recent">Most Recent</option>
<option value="name">Name AZ</option>
</select>
</div>
${filterPanel}
</div>
${table.html}
</div>
</div>`;
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 => `<option>${r.name}</option>`).join('');
UI.modal({ title: 'Bulk Assign Recruiter', subtitle: ids.length + ' candidates',
body: `<div class="form-field"><label>Assign to</label><select id="bulkRec">${opts}</select></div>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Candidates._bulkAssign()">Assign</button>` });
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) => `<div class="flex items-center gap-12" style="margin-bottom:12px"><span style="width:110px;font-size:13px">${label}</span><div style="flex:1">${UI.pbar(val)}</div><b style="width:42px;text-align:right">${val}%</b></div>`;
const body = `
<div class="recc-banner ${recCls}">
<span class="recc-icn">${UI.icon(c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle')}</span>
<div style="flex:1"><div class="fw-600" style="font-size:15px">${c.recommendation}</div>
<div style="opacity:.85;font-size:13px">${c.name} for ${c.jobTitle}</div></div>
</div>
<div class="grid g-2" style="align-items:center;margin-bottom:20px">
<div style="text-align:center">
<div class="ats-ring" style="--pct:${c.aiScore};--c:${ringColor}"><div class="ats-val"><div class="ats-num">${c.aiScore}</div><div class="ats-lbl">ATS MATCH</div></div></div>
</div>
<div>
${subRow('Skills', sub.skills)}
${subRow('Experience', sub.experience)}
${subRow('Education', sub.education)}
${subRow('Keywords', sub.keywords)}
${subRow('Location', sub.location)}
${subRow('Salary', sub.salary)}
</div>
</div>
<div class="form-section-title" style="margin-top:0">Matched Skills (${c.matchedSkills.length})</div>
<div class="k-tags" style="margin-bottom:16px">${c.matchedSkills.length ? c.matchedSkills.map(s => `<span class="skill-pill skill-matched">${UI.icon('check')} ${s}</span>`).join('') : '<span class="text-muted">—</span>'}</div>
<div class="form-section-title" style="margin-top:0">Missing Skills (${c.missingSkills.length})</div>
<div class="k-tags">${c.missingSkills.length ? c.missingSkills.map(s => `<span class="skill-pill skill-missing">${UI.icon('x')} ${s}</span>`).join('') : '<span class="text-muted">None — full match</span>'}</div>
<div class="divider"></div>
<p class="text-muted text-sm">${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.</p>`;
UI.modal({
title: 'ATS Match Analysis', subtitle: c.id + ' · ' + c.jobTitle, body, size: 'modal-lg',
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.closeModal();Candidates.openProfile('${c.id}')">View Full Profile</button>`
});
};
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 = `
<div class="profile-hero">
${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')}
<div style="flex:1">
<div class="ph-name">${c.name}</div>
<div class="ph-role">${c.currentTitle} at ${c.currentCompany}</div>
<div class="ph-tags">${UI.badge(c.stage)} ${UI.badge(c.source, 'b-gray')}
<span class="badge b-plain b-indigo badge-plain">${c.experience} yrs exp</span></div>
</div>
<div style="text-align:center">${UI.scoreChip(c.aiScore)}<div class="cell-sub" style="margin-top:4px">AI Match</div></div>
</div>
<div class="tabs" id="canTabs" style="margin-top:22px">
${tabs.map((t, i) => `<div class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${t}</div>`).join('')}
</div>
<div id="canPanes">
${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)}
</div>`;
const footer = `<button class="btn btn-ghost star-btn ${c.favorite ? 'on' : ''}" style="margin-right:auto" onclick="Candidates.toggleFav('${c.id}',this)">${UI.icon('star')} ${c.favorite ? 'Favorited' : 'Favorite'}</button>
<button class="btn btn-secondary" onclick="Candidates.atsMatch('${c.id}')">${UI.icon('target')} ATS Match</button>
<button class="btn btn-secondary" onclick="UI.toast('Email drafted','info')">${UI.icon('mail')} Message</button>
<button class="btn btn-primary" onclick="Candidates.advance('${c.id}');UI.closeModal()">${UI.icon('check')} Advance Stage</button>`;
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 = `
<div class="info-grid" style="margin-bottom:20px">
<div class="info-item"><div class="il">Email</div><div class="iv">${c.email}</div></div>
<div class="info-item"><div class="il">Phone</div><div class="iv">${c.phone}</div></div>
<div class="info-item"><div class="il">Location</div><div class="iv">${c.location}</div></div>
<div class="info-item"><div class="il">Applied For</div><div class="iv">${c.jobTitle}</div></div>
<div class="info-item"><div class="il">Current Company</div><div class="iv">${c.currentCompany}</div></div>
<div class="info-item"><div class="il">Experience</div><div class="iv">${c.experience} years</div></div>
<div class="info-item"><div class="il">Education</div><div class="iv">${c.education}</div></div>
<div class="info-item"><div class="il">Source</div><div class="iv">${c.source}</div></div>
<div class="info-item"><div class="il">Recruiter</div><div class="iv">${c.recruiter}</div></div>
<div class="info-item"><div class="il">Applied On</div><div class="iv">${DB.fmtDate(c.applied)}</div></div>
<div class="info-item"><div class="il">Expected Salary</div><div class="iv">${DB.moneyK(c.salary)}</div></div>
<div class="info-item"><div class="il">Rating</div><div class="iv">⭐ ${c.rating} / 5.0</div></div>
</div>
<div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:8px">Skills</div>
<div class="k-tags">${c.skills.map(s => `<span class="tag">${s}</span>`).join('')}</div>`;
} else if (name === 'Resume') {
content = `<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
<h3 style="margin-bottom:4px">${c.name}</h3><p class="text-muted">${c.currentTitle} · ${c.location}</p>
<div class="divider"></div>
<div class="form-section-title" style="margin-top:0">Summary</div>
<p class="text-muted">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.</p>
<div class="form-section-title">Experience</div>
<div class="info-item"><div class="iv">${c.currentTitle} ${c.currentCompany}</div><div class="il" style="text-transform:none">2021 Present</div></div>
<div class="info-item" style="margin-top:10px"><div class="iv">Associate ${DB.pick(DB.companies)}</div><div class="il" style="text-transform:none">2018 2021</div></div>
<div class="form-section-title">Education</div>
<div class="iv">${c.education}</div>
</div></div>
<button class="btn btn-secondary" style="margin-top:14px" onclick="UI.toast('Downloading resume.pdf','info')">${UI.icon('download')} Download PDF</button>`;
} 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 = `<div class="timeline">${events.map(e => `
<div class="tl-item"><div class="tl-dot">${UI.icon(e.icon)}</div>
<div class="tl-title">${e.title}</div><div class="tl-meta">${e.meta}</div><div class="tl-desc">${e.desc}</div></div>`).join('')}</div>`;
} else if (name === 'Interview') {
const ivs = DB.interviews.filter(i => i.candidateId === c.id);
content = ivs.length ? `<div class="list-tight">${ivs.map(iv => `
<div class="list-row"><span class="kpi-icn i-blue" style="width:38px;height:38px;border-radius:10px">${UI.icon('calendar')}</span>
<div class="lr-main"><div class="lr-title">${iv.type}</div><div class="lr-sub">${DB.fmtDate(iv.when)} · ${iv.meeting}</div></div>
<div class="lr-right">${UI.badge(iv.status)}</div></div>`).join('')}</div>`
: `<div class="empty-state">${UI.icon('calendar')}<h3>No interviews scheduled</h3><p>Schedule an interview to get started.</p>
<button class="btn btn-primary btn-sm" style="margin-top:10px" onclick="UI.toast('Interview scheduler opened','info')">Schedule Interview</button></div>`;
} else if (name === 'Notes') {
content = `
<div class="form-field"><label>Add a note</label><textarea id="noteInput" placeholder="Write a private note about this candidate"></textarea></div>
<button class="btn btn-primary btn-sm" style="margin:10px 0 18px" onclick="UI.toast('Note saved','success')">${UI.icon('plus')} Add Note</button>
<div class="list-tight">
<div class="list-row">${UI.avatar(c.recruiter)}<div class="lr-main"><div class="lr-title">${c.recruiter}</div><div class="lr-sub" style="color:var(--text-2)">Strong communication skills, great culture fit. Recommend advancing.</div><div class="lr-sub">2 days ago</div></div></div>
<div class="list-row">${UI.avatar('Asfand Ahmed', 'AA')}<div class="lr-main"><div class="lr-title">Asfand Ahmed</div><div class="lr-sub" style="color:var(--text-2)">Reviewed portfolio impressive work. Schedule technical round.</div><div class="lr-sub">4 days ago</div></div></div>
</div>`;
} else if (name === 'Activity') {
content = `<div class="list-tight">
<div class="list-row"><span class="kpi-icn i-green" style="width:34px;height:34px;border-radius:9px">${UI.icon('eye')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Profile viewed by ${c.recruiter}</div><div class="lr-sub">1h ago</div></div></div>
<div class="list-row"><span class="kpi-icn i-blue" style="width:34px;height:34px;border-radius:9px">${UI.icon('mail')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Email sent: Interview invitation</div><div class="lr-sub">1 day ago</div></div></div>
<div class="list-row"><span class="kpi-icn i-amber" style="width:34px;height:34px;border-radius:9px">${UI.icon('star')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Assessment score updated to ${c.aiScore}%</div><div class="lr-sub">2 days ago</div></div></div>
<div class="list-row"><span class="kpi-icn i-purple" style="width:34px;height:34px;border-radius:9px">${UI.icon('user-plus')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Applied for ${c.jobTitle}</div><div class="lr-sub">${DB.fmtDate(c.applied)}</div></div></div>
</div>`;
} 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 = `<div class="list-tight">${docs.map(d => `
<div class="list-row"><span class="kpi-icn i-red" style="width:38px;height:38px;border-radius:10px">${UI.icon('file')}</span>
<div class="lr-main"><div class="lr-title">${d.n}</div><div class="lr-sub">${d.s}</div></div>
<button class="act-btn" onclick="UI.toast('Downloading ${d.n}','info')">${UI.icon('download')}</button></div>`).join('')}</div>`;
} else if (name === 'Feedback') {
const scores = ['Strong Hire', 'Hire', 'Lean Hire'];
content = `<div class="list-tight">
${[0, 1, 2].map(i => `<div class="list-row">${UI.avatar(DB.recruiters[i].name, DB.recruiters[i].initials, DB.recruiters[i].color)}
<div class="lr-main"><div class="lr-title">${DB.recruiters[i].name}</div><div class="lr-sub" style="color:var(--text-2)">${['Excellent technical depth and clear communication.', 'Good problem solving, would benefit from more system design exposure.', 'Solid candidate, positive team energy.'][i]}</div></div>
<div class="lr-right">${UI.badge(scores[i])}</div></div>`).join('')}
</div>
<button class="btn btn-primary btn-sm" style="margin-top:14px" onclick="UI.toast('Scorecard form opened','info')">${UI.icon('plus')} Submit Scorecard</button>`;
}
return `<div class="tab-pane ${active}">${content}</div>`;
};
Candidates.openAdd = function () {
const opt = (arr) => arr.map(o => `<option>${o}</option>`).join('');
const body = `<form id="canForm" novalidate><div class="form-grid">
<div class="form-field"><label>Full Name <span class="req">*</span></label><input name="name" placeholder="Jane Doe"/><span class="field-error">Required</span></div>
<div class="form-field"><label>Email <span class="req">*</span></label><input name="email" type="email" placeholder="jane@email.com"/><span class="field-error">Valid email required</span></div>
<div class="form-field"><label>Phone</label><input name="phone" placeholder="+1 (555) 000-0000"/></div>
<div class="form-field"><label>Applied Job <span class="req">*</span></label><select name="job">${opt(DB.jobs.filter(j => j.status === 'Open').map(j => j.title))}</select></div>
<div class="form-field"><label>Experience (years)</label><input name="experience" type="number" value="3"/></div>
<div class="form-field"><label>Current Company</label><input name="company" placeholder="Acme Inc."/></div>
<div class="form-field"><label>Source</label><select name="source">${opt(DB.sources)}</select></div>
<div class="form-field"><label>Stage</label><select name="stage">${opt(DB.stages)}</select></div>
</div></form>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="Candidates._add()">${UI.icon('check')} Add Candidate</button>`;
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();
};

View File

@ -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 `<span class="trend trend-flat">${txt}</span>`;
const cls = dir === 'up' ? 'trend-up' : 'trend-down';
const ic = dir === 'up' ? 'trending-up' : 'trending-down';
return `<span class="trend ${cls}">${UI.icon(ic)}${txt}</span>`;
}
const kpiHtml = arr => arr.map(c => `
<div class="kpi">
<div class="kpi-top">
<span class="kpi-label">${c.label}</span>
<span class="kpi-icn ${c.cls}">${UI.icon(c.icon)}</span>
</div>
<div class="kpi-value">${c.value}</div>
<div class="kpi-foot">${trendHtml(c.dir, c.trend)}<span class="kpi-foot-text">${c.foot}</span></div>
</div>`).join('');
// upcoming interviews
const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 5);
const upcomingHtml = upcoming.length ? upcoming.map(iv => `
<div class="list-row" onclick="Router.go('interviews')" style="cursor:pointer">
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
<div class="lr-main">
<div class="lr-title">${iv.candidate}</div>
<div class="lr-sub">${iv.type} · ${iv.jobTitle}</div>
</div>
<div class="lr-right">
<div class="fw-600 text-sm">${DB.fmtShort(iv.when)}</div>
<div class="lr-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div>
</div>
</div>`).join('') : '<div class="empty-state" style="padding:30px">No upcoming interviews</div>';
// recent applications
const recentApps = [...DB.candidates].sort((a, b) => b.applied - a.applied).slice(0, 5);
const recentHtml = recentApps.map(c => `
<div class="list-row" onclick="Candidates.openProfile('${c.id}')" style="cursor:pointer">
${UI.avatar(c.name, c.initials, c.color)}
<div class="lr-main">
<div class="lr-title">${c.name}</div>
<div class="lr-sub">${c.jobTitle}</div>
</div>
<div class="lr-right">${UI.scoreChip(c.aiScore)}</div>
</div>`).join('');
// activity feed
const activityHtml = DB.activity.slice(0, 8).map(a => `
<div class="list-row">
<span class="kpi-icn ${a.color}" style="width:36px;height:36px;border-radius:9px">${UI.icon(a.icon)}</span>
<div class="lr-main">
<div class="lr-sub" style="color:var(--text-2);font-size:13px">${a.html}</div>
<div class="lr-sub">${DB.relTime(a.time)}</div>
</div>
</div>`).join('');
// recruiter performance
const topRecs = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5);
const recPerf = topRecs.map(r => `
<div class="list-row">
${UI.avatar(r.name, r.initials, r.color)}
<div class="lr-main">
<div class="lr-title">${r.name}</div>
<div class="lr-sub">${r.openReqs} open reqs · ${r.avgTimeToHire}d avg</div>
</div>
<div class="lr-right"><div class="fw-600">${r.hires}</div><div class="lr-sub">hires</div></div>
</div>`).join('');
const html = `
<div class="page">
<div class="page-head">
<div>
<h1 class="page-title">Good morning, Asfand 👋</h1>
<p class="page-sub">Here's what's happening with your hiring today Thursday, July 9, 2026</p>
</div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="Router.go('reports')">${UI.icon('download')} Export</button>
<button class="btn btn-primary" onclick="Jobs.openCreate()">${UI.icon('plus')} Create Job</button>
</div>
</div>
<div class="grid g-kpi">${kpiHtml(kpiCards)}</div>
<div class="grid g-kpi mt-18">${kpiHtml(kpiCards2)}</div>
<div class="grid g-2-1 mt-18">
<div class="card">
<div class="card-head">
<div><h3>Hiring Trend</h3><span class="ch-sub">Hires vs applications over the last 7 months</span></div>
<div class="pill-tabs"><span class="pill-tab active">7M</span><span class="pill-tab">1Y</span></div>
</div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartTrend" height="280"></canvas></div>
${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}
</div>
</div>
<div class="card">
<div class="card-head"><div><h3>Candidate Pipeline</h3><span class="ch-sub">Active by stage</span></div></div>
<div class="card-body">
<div class="chart-wrap"><canvas id="chartPipeline" height="280"></canvas></div>
</div>
</div>
</div>
<div class="grid g-2-1 mt-18">
<div class="card">
<div class="card-head"><div><h3>Upcoming Interviews</h3><span class="ch-sub">Next scheduled sessions</span></div>
<button class="btn btn-ghost btn-sm" onclick="Router.go('interviews')">View all</button></div>
<div class="card-body"><div class="list-tight">${upcomingHtml}</div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Source Analytics</h3><span class="ch-sub">Where candidates come from</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="chartSource" height="240"></canvas></div></div>
</div>
</div>
<div class="grid g-3 mt-18">
<div class="card">
<div class="card-head"><div><h3>Recent Applications</h3></div>
<button class="btn btn-ghost btn-sm" onclick="Router.go('candidates')">View all</button></div>
<div class="card-body"><div class="list-tight">${recentHtml}</div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Recruiter Performance</h3></div></div>
<div class="card-body"><div class="list-tight">${recPerf}</div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Recent Activity</h3></div></div>
<div class="card-body" style="max-height:360px;overflow-y:auto"><div class="list-tight">${activityHtml}</div></div>
</div>
</div>
</div>`;
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)
});
}
};
};

View File

@ -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 = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">CV Import</h1><p class="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p></div>
<div class="page-head-actions">
<span class="integration-status pending"><span class="pulse"></span>AI Resume Parser · Ready</span>
</div>
</div>
<div class="grid g-2-1">
<div>
<div class="card mb-18"><div class="card-body">
<div class="dropzone" id="dropzone">
<div class="dz-icn">${UI.icon('upload')}</div>
<h3>Drag &amp; drop resumes here</h3>
<p class="text-muted" style="margin-bottom:16px">or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files</p>
<button class="btn btn-primary" id="browseBtn">${UI.icon('upload')} Browse Files</button>
<div class="flex items-center gap-8" style="justify-content:center;margin-top:16px">
${['PDF', 'DOC', 'DOCX', 'ZIP'].map(t => `<span class="badge b-gray badge-plain">${t}</span>`).join('')}
</div>
</div>
<div class="flex items-center gap-8" style="margin-top:16px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" onclick="CVImport.simulate(3)">${UI.icon('sparkles')} Simulate 3 files</button>
<button class="btn btn-secondary btn-sm" onclick="CVImport.simulate(1,true)">${UI.icon('layers')} Simulate ZIP (8 CVs)</button>
<span class="text-muted text-sm" style="margin-left:auto">Files are processed locally in this demo</span>
</div>
</div></div>
<div class="card" id="queueCard" style="display:none">
<div class="card-head"><div><h3>Processing Queue</h3><span class="ch-sub" id="queueSub">0 files</span></div>
<button class="btn btn-primary btn-sm" id="importAllBtn" onclick="CVImport.importAll()">${UI.icon('check')} Import All</button></div>
<div class="card-body" id="queueList"></div>
</div>
</div>
<div class="card" style="align-self:start">
<div class="card-head"><div><h3>Auto-Processing</h3><span class="ch-sub">What happens on upload</span></div></div>
<div class="card-body"><div class="timeline">
${[
{ 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 => `<div class="tl-item"><div class="tl-dot">${UI.icon(s.i)}</div><div class="tl-title">${s.t}</div><div class="tl-desc">${s.d}</div></div>`).join('')}
</div></div>
</div>
</div>
</div>`;
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 => `
<div class="upload-row">
<span class="attach-icn" style="width:38px;height:38px">${UI.icon('file')}</span>
<div style="flex:1;min-width:0">
<div class="flex items-center gap-8"><span class="fw-600 text-sm">${i.name}</span>
${i.duplicate ? '<span class="badge b-red badge-plain" style="padding:1px 7px;font-size:10px">DUPLICATE</span>' : ''}</div>
<div class="cell-sub">${i.file} · ${i.size}</div>
${i.status === 'Uploading' || i.status === 'Parsing' ? `<div class="upload-progress" style="margin-top:6px"><div class="upload-progress-fill" style="width:${i.progress}%"></div></div>` :
`<div class="cell-sub" style="margin-top:4px">Best match: <b>${i.job.title}</b></div>`}
</div>
<div style="text-align:right;flex-shrink:0">
${i.status === 'Ready' ? UI.scoreChip(i.atsScore) : `<span class="badge ${i.status === 'Parsing' ? 'b-amber' : 'b-blue'}">${i.status}${i.status === 'Uploading' ? ' ' + i.progress + '%' : ''}</span>`}
</div>
<div style="flex-shrink:0">
${i.imported ? `<span class="badge b-green">Imported</span>` :
i.status === 'Ready' ? `<button class="btn btn-primary btn-sm" onclick="CVImport.importOne('${i.id}')">Import</button>` :
`<button class="act-btn" disabled>${UI.icon('clock')}</button>`}
</div>
</div>`).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: `<div class="flex gap-16 items-center"><span class="kpi-icn i-amber" style="width:48px;height:48px;border-radius:12px;flex-shrink:0">${UI.icon('users')}</span>
<div><p class="fw-600" style="font-size:15px">A similar candidate already exists</p>
<p class="text-muted" style="margin-top:4px">${i.name} matches an existing profile (95% similarity on name + email). Importing will create a duplicate.</p></div></div>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-secondary" onclick="UI.closeModal();UI.toast('Merged into existing profile','success')">Merge</button>
<button class="btn btn-primary" onclick="UI.closeModal();CVImport._doImport('${id}')">Import Anyway</button>`
});
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');
};

View File

@ -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 `<span class="source-chip" style="--chip:${m.color}"><span class="source-dot"></span>${item.source}</span>`;
}
function renderList() {
const el = document.getElementById('inboxList');
if (!el) return;
const list = filtered();
if (!list.length) { el.innerHTML = `<div class="empty-state">${UI.icon('inbox')}<h3>Nothing here</h3><p>No applications in this view.</p></div>`; return; }
el.innerHTML = list.map(i => `
<div class="inbox-item ${i.unread ? 'unread' : ''} ${state.selected === i.id ? 'active' : ''}" data-id="${i.id}">
${UI.avatar(i.name, i.initials, i.color)}
<div class="ii-main">
<div class="ii-name">${i.name} ${i.duplicate ? '<span class="badge b-red badge-plain" style="padding:1px 6px;font-size:10px">DUP</span>' : ''}</div>
<div class="ii-pos">${i.position}</div>
<div class="ii-meta">${sourceChip(i)} ${UI.badge(i.processing)}</div>
</div>
<div style="text-align:right;flex-shrink:0">
<div class="ii-time">${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}</div>
<div style="margin-top:6px">${UI.scoreChip(i.atsScore)}</div>
</div>
</div>`).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 = `<div class="empty-state" style="padding:100px 20px">${UI.icon('inbox')}<h3>Select an application</h3><p>Choose an item from the list to view details and take action.</p></div>`; return; }
const rec = DB.atsRecommendationClass;
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match';
el.innerHTML = `
<div style="padding:24px">
<div class="flex items-center gap-16" style="margin-bottom:20px">
${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')}
<div style="flex:1"><div class="ph-name" style="font-size:19px">${i.name}</div>
<div class="ph-role">${i.position}</div>
<div class="ph-tags" style="margin-top:8px">${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}</div>
</div>
<div style="text-align:center">
<div class="ats-ring" style="width:84px;height:84px;--pct:${i.atsScore};--c:${i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'}">
<div class="ats-val"><div class="ats-num" style="font-size:22px">${i.atsScore}</div></div></div>
<div class="cell-sub" style="margin-top:4px">ATS Score</div>
</div>
</div>
<div class="info-grid" style="margin-bottom:20px">
<div class="info-item"><div class="il">Email</div><div class="iv">${i.email}</div></div>
<div class="info-item"><div class="il">Phone</div><div class="iv">${i.phone}</div></div>
<div class="info-item"><div class="il">Experience</div><div class="iv">${i.experience} years</div></div>
<div class="info-item"><div class="il">Assigned Recruiter</div><div class="iv">${i.recruiter}</div></div>
<div class="info-item"><div class="il">Received</div><div class="iv">${DB.fmtDate(i.received)}</div></div>
<div class="info-item"><div class="il">Match</div><div class="iv">${UI.badge(recLabel, rec(recLabel))}</div></div>
</div>
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-bottom:20px"><div class="card-body">
<div class="flex items-center gap-12" style="justify-content:space-between;margin-bottom:12px">
<div class="fw-600">${UI.icon('paperclip')} ${i.attachment}</div>
<button class="btn btn-secondary btn-sm" onclick="Inbox.viewResume('${i.id}')">${UI.icon('eye')} Preview</button>
</div>
<div class="resume-thumb">${Inbox._resumeText(i)}</div>
</div></div>
<div class="flex gap-8" style="flex-wrap:wrap">
<button class="btn btn-primary" onclick="Inbox.import('${i.id}')">${UI.icon('user-plus')} Import Candidate</button>
<button class="btn btn-secondary" onclick="Inbox.parse('${i.id}')">${UI.icon('sparkles')} Parse Resume</button>
<button class="btn btn-secondary" onclick="Inbox.assign('${i.id}')">${UI.icon('users')} Assign Recruiter</button>
<button class="btn btn-secondary" onclick="Inbox.moveToPipeline('${i.id}')">${UI.icon('layers')} Move to Pipeline</button>
<button class="btn btn-secondary" onclick="Inbox.note('${i.id}')">${UI.icon('edit')} Add Note</button>
<button class="btn btn-ghost" style="color:var(--danger)" onclick="Inbox.reject('${i.id}')">${UI.icon('x')} Reject</button>
</div>
</div>`;
}
function renderBody() {
const body = document.getElementById('inboxBody');
if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; }
body.innerHTML = `
<div class="split">
<div class="split-list">
<div style="padding:12px 16px;border-bottom:1px solid var(--border)">
<div class="toolbar-search" style="max-width:none"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><input id="inboxSearch" placeholder="Search applications…" value="${state.q}"/></div>
</div>
<div id="inboxList"></div>
</div>
<div class="split-detail" id="inboxDetail"></div>
</div>`;
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 => `<div class="tab ${t === state.tab ? 'active' : ''}" data-tab="${t}">${t} <span class="k-count" style="margin-left:4px">${c[t]}</span></div>`).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Recruitment Inbox</h1><p class="page-sub">Every candidate, every source one unified queue</p></div>
<div class="page-head-actions">
<span class="integration-status"><span class="pulse"></span>Microsoft Graph API · Connected</span>
<button class="btn btn-secondary" onclick="UI.toast('Syncing all sources…','info');setTimeout(()=>UI.toast('Inbox synced','success'),900)">${UI.icon('refresh')} Sync</button>
<button class="btn btn-primary" onclick="Router.go('import')">${UI.icon('upload')} Upload CVs</button>
</div>
</div>
<div class="card">
<div class="tabs" id="inboxTabs" style="margin:0 16px;padding-top:8px">${tabHtml}</div>
<div id="inboxBody"></div>
</div>
</div>`;
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 (2021Present)\n${DB.pick(DB.companies)} — Associate (20182021)\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: `<div class="resume-thumb" style="max-height:none;font-size:12px">${Inbox._resumeText(i)}</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.closeModal();Inbox.import('${id}')">${UI.icon('user-plus')} Import Candidate</button>`,
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 => `<option ${r.name === i.recruiter ? 'selected' : ''}>${r.name}</option>`).join('');
UI.modal({
title: 'Assign Recruiter', subtitle: i.name,
body: `<div class="form-field"><label>Recruiter</label><select id="assignRec">${opts}</select></div>
<p class="text-muted text-sm" style="margin-top:10px">Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.</p>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Inbox._doAssign('${id}')">Assign</button>`
});
};
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: `<div class="form-field"><label>Note</label><textarea id="inboxNote" placeholder="Add a note about this application…"></textarea></div>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Note added','success')">${UI.icon('check')} Save Note</button>`
});
};
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 `
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px">
<span class="integration-status"><span class="pulse"></span>Outlook · Microsoft Graph API</span>
<span class="text-muted text-sm" id="emailSyncMeta">Loading</span>
<button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="Inbox.syncMailbox()">${UI.icon('refresh')} Sync Mailbox</button>
</div>
<div class="split">
<div class="split-list" id="emailList"><div class="empty-state">${UI.icon('mail')}<h3>Loading</h3><p>Fetching mailbox from the server.</p></div></div>
<div class="split-detail" id="emailDetail"></div>
</div>`;
};
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 = `<div class="empty-state" style="padding:100px 20px">${UI.icon('mail')}<h3>Select an email</h3><p>Preview email body and resume attachments here.</p></div>`; return; }
detail.innerHTML = `<div style="padding:24px">
<div class="flex items-center gap-12" style="margin-bottom:6px">
<h2 style="font-size:18px;flex:1">${e.subject}</h2>${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}</div>
<div class="flex items-center gap-12" style="margin-bottom:20px">
${UI.avatar(e.from, e.initials, e.color)}
<div><div class="fw-600">${e.from}</div><div class="cell-sub">${e.fromEmail} · ${DB.fmtDate(e.when)}</div></div>
</div>
<div class="email-preview" style="margin-bottom:18px">${e.body}</div>
<div class="attach-card" style="margin-bottom:18px">
<span class="attach-icn">${UI.icon('file')}</span>
<div style="flex:1"><div class="fw-600">${e.attachment}</div><div class="cell-sub">${e.attachmentSize} · PDF</div></div>
<div class="flex items-center gap-8">${UI.scoreChip(e.atsScore)}
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Opening attachment preview','info')">${UI.icon('eye')} Preview</button></div>
</div>
<div class="flex gap-8">
${e.imported ? `<button class="btn btn-secondary" disabled>${UI.icon('check')} Already Imported</button>` :
`<button class="btn btn-primary" onclick="Inbox._importEmail('${e.id}')">${UI.icon('user-plus')} Import Candidate</button>`}
<button class="btn btn-secondary" onclick="UI.toast('Reply drafted','info')">${UI.icon('mail')} Reply</button>
<button class="btn btn-ghost" style="color:var(--danger)" onclick="UI.toast('Email archived','info')">${UI.icon('trash')} Archive</button>
</div>
</div>`;
}
function paintList() {
const list = document.getElementById('emailList');
const meta = document.getElementById('emailSyncMeta');
if (!list) return;
if (!Inbox._emails.length) {
list.innerHTML = `<div class="empty-state">${UI.icon('mail')}<h3>Nothing here</h3><p>No emails in the mailbox.</p></div>`;
} else {
list.innerHTML = Inbox._emails.map(e => `
<div class="inbox-item ${e.unread ? 'unread' : ''} ${state.emailSelected === e.id ? 'active' : ''}" data-email="${e.id}">
${UI.avatar(e.from, e.initials, e.color)}
<div class="ii-main">
<div class="ii-name">${e.from}</div>
<div class="ii-pos">${e.subject}</div>
<div class="ii-meta"><span class="source-chip" style="--chip:#0078d4"><svg viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Outlook</span>${e.imported ? UI.badge('Imported', 'b-green') : ''}</div>
</div>
<div class="ii-time">${DB.fmtShort(e.when)}</div>
</div>`).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 = `<div class="empty-state">${UI.icon('mail')}<h3>Couldn't load mailbox</h3><p>${err.message || 'Request failed'}</p></div>`;
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');
};

View File

@ -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 => `<div class="user-cell">${UI.avatar(iv.candidate, iv.candInitials, iv.color)}<div><div class="cell-primary">${iv.candidate}</div><div class="cell-sub">${iv.jobTitle}</div></div></div>` },
{ 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 => `<div class="text-sm fw-600">${DB.fmtShort(iv.when)}</div><div class="cell-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · ${iv.duration}m</div>` },
{ key: 'meeting', label: 'Type', render: iv => `<span class="flex items-center gap-8">${UI.icon(iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map')} ${iv.meeting}</span>` },
{ 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) : '<span class="text-muted">—</span>' },
{ key: '_a', label: 'Actions', align: 'right', render: iv => `
<div class="row-actions">
<button class="act-btn" data-tip="View candidate" onclick="Candidates.openProfile('${iv.candidateId}')">${UI.icon('eye')}</button>
<button class="act-btn" data-tip="Feedback" onclick="Interviews.feedback('${iv.id}')">${UI.icon('star')}</button>
</div>` }
]
});
const statusOpts = ['<option value="">All Status</option>'].concat(['Scheduled', 'Completed', 'Cancelled', 'No Show'].map(s => `<option>${s}</option>`)).join('');
const typeOpts = ['<option value="">All Rounds</option>'].concat(DB.interviewTypes.map(t => `<option>${t}</option>`)).join('');
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
const upcomingHtml = upcoming.map(iv => `
<div class="list-row">
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
<div class="lr-main"><div class="lr-title">${iv.candidate}</div><div class="lr-sub">${iv.type} · ${iv.meeting}</div></div>
<div class="lr-right"><div class="fw-600 text-sm">${DB.fmtShort(iv.when)}</div><div class="lr-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div></div>
</div>`).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Interviews</h1><p class="page-sub">Manage and track all interview activity</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="Router.go('calendar')">${UI.icon('calendar')} Calendar View</button>
<button class="btn btn-primary" onclick="Interviews.schedule()">${UI.icon('plus')} Schedule Interview</button>
</div>
</div>
<div class="grid g-kpi mb-18">
${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')}
</div>
<div class="grid g-2-1">
<div class="card">
<div class="card-head"><div><h3>All Interviews</h3></div></div>
<div class="card-body" style="padding-bottom:0">
<div class="toolbar">
<div class="toolbar-search">${UI.icon('search')}<input id="ivSearch" placeholder="Search candidate or interviewer…"/></div>
<select class="select" id="ivStatus">${statusOpts}</select>
<select class="select" id="ivType">${typeOpts}</select>
</div>
</div>
${table.html}
</div>
<div class="card" style="align-self:start">
<div class="card-head"><div><h3>Up Next</h3><span class="ch-sub">Scheduled sessions</span></div></div>
<div class="card-body"><div class="list-tight">${upcomingHtml}</div></div>
</div>
</div>
</div>`;
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 => `<option ${t === tmpl ? 'selected' : ''}>${t.name}</option>`).join('');
const ratingRow = (crit) => `
<div class="setting-row" style="padding:12px 0">
<div class="setting-info"><h4>${crit}</h4></div>
<div class="rating-stars" data-crit="${crit}">
${[1, 2, 3, 4, 5].map(n => `<span class="rs" data-val="${n}">${UI.icon('star')}</span>`).join('')}
</div>
</div>`;
const body = `
<div class="flex items-center gap-12 mb-18">${UI.avatar(iv.candidate, iv.candInitials, iv.color, 'avatar-lg')}
<div style="flex:1"><div class="ph-name" style="font-size:17px">${iv.candidate}</div><div class="ph-role">${iv.type} · ${iv.jobTitle}</div></div>
${UI.badge(iv.status)}</div>
<div class="tabs" id="evalTabs" style="margin-bottom:18px">
<div class="tab active" data-etab="0">Dynamic Form</div>
<div class="tab" data-etab="1">Upload Sheet</div>
<div class="tab" data-etab="2">Both</div>
</div>
<div id="evalPanes">
<div class="tab-pane active" data-epane="0">
<div class="form-field" style="margin-bottom:8px"><label>Evaluation Template</label><select id="evalTmpl">${tmplOpts}</select></div>
<div id="critList">${tmpl.criteria.map(ratingRow).join('')}</div>
<div class="form-field" style="margin-top:8px"><label>Comments</label><textarea placeholder="Strengths, concerns, and areas explored"></textarea></div>
<div class="form-field" style="margin-top:14px"><label>Overall Recommendation</label>
<div class="seg" style="margin-top:4px"><button type="button" class="active" data-rec="Hire">Hire</button><button type="button" data-rec="Hold">Hold</button><button type="button" data-rec="Reject">Reject</button></div></div>
</div>
<div class="tab-pane" data-epane="1">
<div class="dropzone" onclick="UI.toast('File picker (demo)','info')" style="padding:32px">
<div class="dz-icn">${UI.icon('upload')}</div>
<h3 style="font-size:15px">Upload evaluation sheet</h3>
<p class="text-muted">PDF, DOC, or DOCX · scanned scorecards supported</p>
<div class="flex items-center gap-8" style="justify-content:center;margin-top:12px">${['PDF', 'DOC', 'DOCX'].map(t => `<span class="badge b-gray badge-plain">${t}</span>`).join('')}</div>
</div>
</div>
<div class="tab-pane" data-epane="2">
<p class="text-muted" style="margin-bottom:14px">Capture structured ratings <b>and</b> attach a signed sheet both are stored on the scorecard.</p>
<div id="critList2">${tmpl.criteria.slice(0, 3).map(ratingRow).join('')}</div>
<div class="upload-row" style="margin-top:12px"><span class="attach-icn" style="width:38px;height:38px">${UI.icon('file')}</span>
<div style="flex:1"><div class="fw-600 text-sm">Interviewer_Scorecard.pdf</div><div class="cell-sub">Attached · 214 KB</div></div>${UI.badge('Uploaded', 'b-green')}</div>
</div>
</div>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Scorecard submitted','success')">${UI.icon('check')} Submit Scorecard</button>`;
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 => `<option>${o}</option>`).join('');
const body = `<form id="schForm"><div class="form-grid">
<div class="form-field col-span-2"><label>Candidate <span class="req">*</span></label><select name="candidate">${opt(DB.candidates.slice(0, 40).map(c => c.name))}</select></div>
<div class="form-field"><label>Interview Round</label><select name="type">${opt(DB.interviewTypes)}</select></div>
<div class="form-field"><label>Meeting Type</label><select name="meeting">${opt(DB.meetingTypes)}</select></div>
<div class="form-field"><label>Date</label><input type="date" name="date"/></div>
<div class="form-field"><label>Time</label><input type="time" name="time" value="14:00"/></div>
<div class="form-field"><label>Duration</label><select name="dur"><option>30 min</option><option>45 min</option><option selected>60 min</option><option>90 min</option></select></div>
<div class="form-field"><label>Interviewer</label><select name="interviewer">${opt(DB.recruiters.concat(DB.managers).map(r => r.name))}</select></div>
</div></form>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Interview scheduled & invite sent','success')">${UI.icon('calendar')} Schedule</button>`;
UI.modal({ title: 'Schedule Interview', subtitle: 'Set up a new interview session', body, footer });
};

View File

@ -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) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div><div class="kpi-foot"><span class="kpi-foot-text">${sub}</span></div></div>`;
// 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 => `<div class="cell-primary">${p.jobTitle}</div><div class="cell-sub">${p.jobId}</div>` },
{ key: 'platform', label: 'Platform', sortable: true, render: p => { const pl = DB.publishPlatforms.find(x => x.name === p.platform) || {}; return `<span class="flex items-center gap-8"><span class="platform-logo" style="width:26px;height:26px;background:${pl.color || '#888'}">${UI.icon(pl.icon || 'briefcase')}</span>${p.platform}</span>`; } },
{ 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 => `<b>${p.apps}</b>` },
{ key: '_conv', label: 'Conversion', sortable: true, sortValue: p => p.apps / p.views, render: p => `<span class="badge b-indigo badge-plain">${((p.apps / p.views) * 100).toFixed(1)}%</span>` },
{ key: '_a', label: '', align: 'right', render: p => `<button class="act-btn" data-tip="Manage" onclick="UI.toast('Managing ${p.platform} posting','info')">${UI.icon('external')}</button>` }
]
});
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Job Board</h1><p class="page-sub">Publish requisitions across channels and track performance</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="Router.go('analytics')">${UI.icon('trending-up')} Analytics</button>
<button class="btn btn-primary" onclick="JobBoard.publishFlow()">${UI.icon('send')} Publish a Job</button>
</div>
</div>
<div class="grid g-kpi mb-18">
${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')}
</div>
<div class="grid g-2-1 mb-18">
<div class="card">
<div class="card-head"><div><h3>Platform Performance</h3><span class="ch-sub">Applications by channel</span></div></div>
<div class="card-body"><div class="chart-wrap"><canvas id="jbChart" height="300"></canvas></div></div>
</div>
<div class="card">
<div class="card-head"><div><h3>Connected Platforms</h3></div></div>
<div class="card-body"><div class="list-tight">
${DB.publishPlatforms.map(p => `<div class="list-row">
<span class="platform-logo" style="background:${p.color}">${UI.icon(p.icon)}</span>
<div class="lr-main"><div class="lr-title">${p.name}</div><div class="lr-sub">${p.cost === 'Free' ? 'Free posting' : 'Paid · ' + p.cost}</div></div>
${p.connected ? UI.badge('Connected', 'b-green') : `<button class="btn btn-secondary btn-sm" onclick="UI.toast('Connecting ${p.name}…','info')">Connect</button>`}
</div>`).join('')}
</div></div>
</div>
</div>
<div class="card">
<div class="card-head"><div><h3>Active Postings</h3><span class="ch-sub">${DB.publishings.length} live postings across ${platRows.length} platforms</span></div>
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Performance report exported','success')">${UI.icon('download')} Export</button></div>
${table.html}
</div>
</div>`;
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 = `<div class="stepper">${steps.map((s, i) => {
const n = i + 1;
const cls = n < state.step ? 'done' : n === state.step ? 'active' : '';
return `<div class="step ${cls}"><div class="step-num">${n < state.step ? '✓' : n}</div><div class="step-label">${s}</div></div>${i < steps.length - 1 ? `<div class="step-line ${n < state.step ? 'done' : ''}"></div>` : ''}`;
}).join('')}</div>`;
let body = stepper;
if (state.step === 1) {
const opts = DB.jobs.filter(j => j.status !== 'Draft').map(j => `<option value="${j.id}" ${j.id === state.jobId ? 'selected' : ''}>${j.title} · ${j.id}</option>`).join('');
const job = DB.getJob(state.jobId);
body += `<div class="form-field"><label>Select requisition to publish</label><select id="pubJob">${opts}</select></div>
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-top:16px"><div class="card-body">
<div class="flex items-center gap-12"><span class="kpi-icn i-indigo" style="width:44px;height:44px;border-radius:12px">${UI.icon('briefcase')}</span>
<div><div class="fw-600">${job.title}</div><div class="cell-sub">${job.department} · ${job.location} · ${job.type}</div></div></div>
</div></div>`;
} else if (state.step === 2) {
body += `<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
<div class="flex items-center gap-12" style="margin-bottom:14px"><span class="kpi-icn i-green" style="width:44px;height:44px;border-radius:12px">${UI.icon('check-circle')}</span>
<div><div class="fw-600">Approval granted</div><div class="cell-sub">Approved by Department Head · Budget confirmed</div></div></div>
<div class="setting-row" style="padding:10px 0"><div class="setting-info"><h4>Hiring Manager sign-off</h4></div>${UI.badge('Approved', 'b-green')}</div>
<div class="setting-row" style="padding:10px 0"><div class="setting-info"><h4>Finance budget approval</h4></div>${UI.badge('Approved', 'b-green')}</div>
<div class="setting-row" style="padding:10px 0;border:none"><div class="setting-info"><h4>Compliance review</h4></div>${UI.badge('Approved', 'b-green')}</div>
</div></div>`;
} else if (state.step === 3) {
body += `<p class="text-muted" style="margin-bottom:14px">Select the platforms to publish this role to</p>
<div class="grid g-2" id="platGrid">
${DB.publishPlatforms.map(p => `<div class="platform-card ${state.platforms.includes(p.name) ? 'selected' : ''} ${!p.connected ? 'disabled' : ''}" data-plat="${p.name}" ${!p.connected ? 'style="opacity:.5;pointer-events:none"' : ''}>
<span class="platform-logo" style="background:${p.color}">${UI.icon(p.icon)}</span>
<div style="flex:1"><div class="fw-600">${p.name}</div><div class="cell-sub">${p.cost === 'Free' ? 'Free' : 'Paid · ' + p.cost}</div></div>
<span class="platform-check">${UI.icon('check')}</span>
</div>`).join('')}
</div>`;
} else if (state.step === 4) {
body += `<div style="text-align:center;padding:20px 0">
<div class="kpi-icn i-green" style="width:64px;height:64px;border-radius:18px;margin:0 auto 16px">${UI.icon('check-circle')}</div>
<h2 style="font-size:20px;margin-bottom:6px">Published Successfully</h2>
<p class="text-muted" style="margin-bottom:20px">${DB.getJob(state.jobId).title} is now live on ${state.platforms.length} platform${state.platforms.length > 1 ? 's' : ''}</p>
<div class="flex gap-8" style="justify-content:center;flex-wrap:wrap">
${state.platforms.map(p => { const pl = DB.publishPlatforms.find(x => x.name === p); return `<span class="badge b-green">${pl.name}</span>`; }).join('')}
</div>
</div>`;
}
let footer;
if (state.step === 4) footer = `<button class="btn btn-primary" onclick="UI.closeModal();Router.reload()">${UI.icon('check')} Done</button>`;
else footer = `<button class="btn btn-secondary" onclick="${state.step === 1 ? 'UI.closeModal()' : 'JobBoard._back()'}">${state.step === 1 ? 'Cancel' : 'Back'}</button>
<button class="btn btn-primary" onclick="JobBoard._next()">${state.step === 3 ? UI.icon('send') + ' Publish' : 'Continue'} </button>`;
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(); };

View File

@ -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 => `<span class="cell-mono">${j.id}</span>` },
{ key: 'title', label: 'Job Title', sortable: true, render: j => `<div class="cell-primary">${j.title}</div><div class="cell-sub">${j.businessUnit} · ${j.grade}</div>` },
{ key: 'department', label: 'Department', sortable: true },
{ key: 'manager', label: 'Hiring Manager', sortable: true, render: j => `<div class="user-cell">${UI.avatar(j.manager)}<span>${j.manager}</span></div>` },
{ key: 'location', label: 'Location', sortable: true, render: j => `<span class="text-muted">${j.location}</span>` },
{ key: 'type', label: 'Type', render: j => UI.badge(j.type, 'b-gray') },
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: j => `<b>${j.applications}</b>` },
{ 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 => `<span class="text-muted">${DB.fmtShort(j.created)}</span>` },
{ key: '_a', label: 'Actions', align: 'right', render: j => `
<div class="row-actions">
<button class="act-btn" data-tip="View" onclick="Jobs.view('${j.id}')">${UI.icon('eye')}</button>
<button class="act-btn" data-tip="Publish" onclick="JobBoard.publishFlow('${j.id}')">${UI.icon('send')}</button>
<button class="act-btn" data-tip="Edit" onclick="Jobs.openEdit('${j.id}')">${UI.icon('edit')}</button>
<button class="act-btn danger" data-tip="Delete" onclick="Jobs.confirmDelete('${j.id}')">${UI.icon('trash')}</button>
</div>` }
]
});
const deptOpts = ['<option value="">All Departments</option>'].concat(DB.departments.map(d => `<option>${d}</option>`)).join('');
const statusOpts = ['<option value="">All Status</option>'].concat(DB.jobStatuses.map(s => `<option>${s}</option>`)).join('');
const typeOpts = ['<option value="">All Types</option>'].concat(DB.empTypes.map(t => `<option>${t}</option>`)).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Jobs</h1><p class="page-sub">${DB.jobs.length} requisitions · ${DB.kpis.openJobs} currently open</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="UI.toast('Jobs exported to CSV','success')">${UI.icon('download')} Export</button>
<button class="btn btn-primary" onclick="Jobs.openCreate()">${UI.icon('plus')} Create Job</button>
</div>
</div>
<div class="card">
<div class="card-body" style="padding-bottom:0">
<div class="toolbar">
<div class="toolbar-search">${UI.icon('search')}<input id="jobSearch" placeholder="Search jobs, IDs, managers…"/></div>
<select class="select" id="jobDept">${deptOpts}</select>
<select class="select" id="jobStatus">${statusOpts}</select>
<select class="select" id="jobType">${typeOpts}</select>
</div>
</div>
${table.html}
</div>
</div>`;
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 = `
<div class="flex items-center gap-16 mb-18">
<span class="kpi-icn i-indigo" style="width:52px;height:52px;border-radius:14px">${UI.icon('briefcase')}</span>
<div>
<div style="font-size:19px;font-weight:700">${j.title}</div>
<div class="text-muted">${j.id} · ${j.department} · ${j.businessUnit}</div>
</div>
<div style="margin-left:auto">${UI.badge(j.status)}</div>
</div>
<div class="info-grid mb-18">
<div class="info-item"><div class="il">Hiring Manager</div><div class="iv">${j.manager}</div></div>
<div class="info-item"><div class="il">Assigned Recruiter</div><div class="iv flex items-center gap-8">${j.recruiter} ${(() => { const r = DB.getRecruiterByName(j.recruiter); return r ? `<span class="badge ${r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green'} badge-plain" style="font-size:10px">${r.workload}% load</span>` : ''; })()} <button class="link-btn" onclick="Jobs.reassign('${j.id}')">Reassign</button></div></div>
<div class="info-item"><div class="il">Location</div><div class="iv">${j.location}</div></div>
<div class="info-item"><div class="il">Employment Type</div><div class="iv">${j.type}</div></div>
<div class="info-item"><div class="il">Grade</div><div class="iv">${j.grade}</div></div>
<div class="info-item"><div class="il">Vacancies</div><div class="iv">${j.vacancies}</div></div>
<div class="info-item"><div class="il">Salary Range</div><div class="iv">${DB.moneyK(j.salaryMin)} ${DB.moneyK(j.salaryMax)}</div></div>
<div class="info-item"><div class="il">Experience</div><div class="iv">${j.experience}</div></div>
<div class="info-item"><div class="il">Education</div><div class="iv">${j.education}</div></div>
<div class="info-item"><div class="il">Deadline</div><div class="iv">${DB.fmtDate(j.deadline)}</div></div>
</div>
<div class="divider"></div>
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Description</div><p style="color:var(--text-2)">${j.description}</p></div>
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Key Responsibilities</div>
<ul style="padding-left:18px;color:var(--text-2)">${j.responsibilities.map(r => `<li>${r}</li>`).join('')}</ul></div>
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Required Skills</div>
<div class="k-tags">${j.skills.map(s => `<span class="tag">${s}</span>`).join('')}</div></div>
<div><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Benefits</div>
<div class="k-tags">${j.benefits.map(s => `<span class="tag">${s}</span>`).join('')}</div></div>
<div class="divider"></div>
<div class="flex items-center gap-12"><span class="text-muted text-sm">Hiring progress</span><div style="flex:1">${UI.pbar(j.progress)}</div><span class="fw-600">${j.progress}%</span></div>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
<button class="btn btn-secondary" onclick="UI.closeModal();JobBoard.publishFlow('${j.id}')">${UI.icon('send')} Publish</button>
<button class="btn btn-primary" onclick="UI.closeModal();Jobs.openEdit('${j.id}')">${UI.icon('edit')} Edit Job</button>`;
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 => `<option ${r.name === j.recruiter ? 'selected' : ''}>${r.name}${r.workload}% load · ${r.openReqs} reqs</option>`).join('');
UI.modal({
title: 'Reassign Recruiter', subtitle: j.title,
body: `<div class="form-field"><label>Assigned Recruiter</label><select id="reassignRec">${DB.recruiters.map(r => `<option value="${r.name}" ${r.name === j.recruiter ? 'selected' : ''}>${r.name}${r.workload}% load</option>`).join('')}</select></div>
<p class="text-muted text-sm" style="margin-top:10px">Workload is recalculated automatically across the recruiter's assigned requisitions.</p>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Jobs._doReassign('${id}')">Reassign</button>`
});
};
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 => `<option ${o === sel ? 'selected' : ''}>${o}</option>`).join('');
const body = `
<form id="jobForm" novalidate>
<div class="form-grid">
<div class="form-field col-span-2">
<label>Job Title <span class="req">*</span></label>
<input name="title" value="${job ? job.title : ''}" placeholder="e.g. Senior Product Designer"/>
<span class="field-error">Job title is required</span>
</div>
<div class="form-field"><label>Department <span class="req">*</span></label>
<select name="department">${opt(DB.departments, job && job.department)}</select></div>
<div class="form-field"><label>Business Unit</label><select name="businessUnit">${opt(DB.businessUnits, job && job.businessUnit)}</select></div>
<div class="form-field"><label>Grade</label><select name="grade">${opt(DB.grades, job && job.grade)}</select></div>
<div class="form-field"><label>Employment Type</label><select name="type">${opt(DB.empTypes, job && job.type)}</select></div>
<div class="form-field"><label>Hiring Manager <span class="req">*</span></label>
<select name="manager">${opt(DB.managers.map(m => m.name), job && job.manager)}</select></div>
<div class="form-field"><label>Recruiter <span class="req">*</span></label>
<select name="recruiter">${opt(DB.recruiters.map(r => r.name), job && job.recruiter)}</select></div>
<div class="form-field"><label>Salary Min ($) <span class="req">*</span></label>
<input name="salaryMin" type="number" value="${job ? job.salaryMin : ''}" placeholder="90000"/>
<span class="field-error">Enter a valid amount</span></div>
<div class="form-field"><label>Salary Max ($)</label><input name="salaryMax" type="number" value="${job ? job.salaryMax : ''}" placeholder="130000"/></div>
<div class="form-field"><label>Experience</label><input name="experience" value="${job ? job.experience : ''}" placeholder="5+ years"/></div>
<div class="form-field"><label>Education</label><select name="education">${opt(DB.educationLevels, job && job.education)}</select></div>
<div class="form-field"><label>Location <span class="req">*</span></label><select name="location">${opt(DB.locations, job && job.location)}</select></div>
<div class="form-field"><label>Vacancies</label><input name="vacancies" type="number" value="${job ? job.vacancies : 1}" min="1"/></div>
<div class="form-field col-span-2"><label>Job Description <span class="req">*</span></label>
<textarea name="description" placeholder="Describe the role…">${job ? job.description : ''}</textarea>
<span class="field-error">Description is required</span></div>
<div class="form-field col-span-2"><label>Responsibilities</label>
<textarea name="responsibilities" placeholder="One per line…">${job ? job.responsibilities.join('\n') : ''}</textarea></div>
<div class="form-field col-span-2"><label>Required Skills</label>
<input name="skills" value="${job ? job.skills.join(', ') : ''}" placeholder="React, TypeScript, System Design"/></div>
<div class="form-field col-span-2"><label>Benefits</label>
<input name="benefits" value="${job ? job.benefits.join(', ') : ''}" placeholder="Equity, 401(k), Unlimited PTO"/></div>
<div class="form-field"><label>Deadline</label><input name="deadline" type="date"/></div>
<div class="form-field"><label>Status</label><select name="status">${opt(DB.jobStatuses, job ? job.status : 'Open')}</select></div>
</div>
</form>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="Jobs._save(${isEdit ? `'${job.id}'` : 'null'})">${UI.icon('check')} ${isEdit ? 'Save Changes' : 'Create Job'}</button>`;
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 = `<div class="flex gap-16 items-center">
<span class="kpi-icn i-red" style="width:48px;height:48px;border-radius:12px;flex-shrink:0">${UI.icon('trash')}</span>
<div><p style="font-weight:600;font-size:15px">Delete "${j.title}"?</p>
<p class="text-muted" style="margin-top:4px">This will permanently remove requisition ${j.id} and its ${j.applications} applications. This action cannot be undone.</p></div></div>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="Jobs._delete('${id}')">${UI.icon('trash')} Delete Job</button>`;
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();
};

View File

@ -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 `<div class="card">
<div class="card-body">
<div class="flex items-center gap-12" style="margin-bottom:14px">
${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')}
<div style="flex:1"><div class="lr-title">${m.name}</div><div class="lr-sub">${m.title}</div></div>
</div>
<div class="grid g-2" style="gap:10px;margin-bottom:14px">
<div class="stat-mini"><span class="stat-mini-val">${m.openReqs}</span><span class="stat-mini-lbl">Open Reqs</span></div>
<div class="stat-mini"><span class="stat-mini-val">${m.teamSize}</span><span class="stat-mini-lbl">Team Size</span></div>
</div>
<div class="divider" style="margin:12px 0"></div>
<div class="flex items-center" style="justify-content:space-between">
<span class="cell-sub">${UI.icon('mail')} ${m.email.split('@')[0]}</span>
<button class="btn btn-ghost btn-sm" onclick="Views._mgrDetail('${m.id}')">View</button>
</div>
</div>
</div>`;
}
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Hiring Managers</h1><p class="page-sub">${DB.managers.length} managers · ${DB.managers.reduce((s, m) => s + m.openReqs, 0)} active requisitions</p></div>
<div class="page-head-actions"><button class="btn btn-primary" onclick="UI.toast('Invite manager','info')">${UI.icon('plus')} Add Manager</button></div>
</div>
<div class="grid g-3">${DB.managers.map(card).join('')}</div>
</div>`;
return { html };
};
Views._mgrDetail = function (id) {
const m = DB.getManager(id);
const jobs = DB.jobs.filter(j => j.manager === m.name);
const body = `
<div class="profile-hero" style="margin-bottom:18px">${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')}
<div><div class="ph-name">${m.name}</div><div class="ph-role">${m.title}</div>
<div class="ph-tags">${UI.badge(m.department, 'b-indigo')}<span class="badge b-gray badge-plain">${m.teamSize} reports</span></div></div></div>
<div class="grid g-3" style="margin-bottom:18px">
<div class="stat-mini"><span class="stat-mini-val">${m.openReqs}</span><span class="stat-mini-lbl">Open Reqs</span></div>
<div class="stat-mini"><span class="stat-mini-val">${jobs.length}</span><span class="stat-mini-lbl">Total Jobs</span></div>
<div class="stat-mini"><span class="stat-mini-val">${jobs.reduce((s, j) => s + j.applications, 0)}</span><span class="stat-mini-lbl">Applications</span></div>
</div>
<div class="form-section-title" style="margin-top:0">Hiring Manager Portal</div>
<div class="grid g-2" style="gap:10px;margin-bottom:16px">
<button class="btn btn-secondary" onclick="UI.closeModal();Jobs.openCreate()">${UI.icon('plus')} Raise Requisition</button>
<button class="btn btn-secondary" onclick="UI.closeModal();Router.go('candidates')">${UI.icon('users')} Review Candidates</button>
<button class="btn btn-secondary" onclick="UI.closeModal();Interviews.schedule()">${UI.icon('calendar')} Schedule Interview</button>
<button class="btn btn-secondary" onclick="UI.closeModal();Router.go('offers')">${UI.icon('check-circle')} Approve Offers</button>
</div>
<div class="form-section-title">Requisitions</div>
<div class="list-tight">${jobs.length ? jobs.map(j => `<div class="list-row" style="cursor:pointer" onclick="UI.closeModal();Jobs.view('${j.id}')">
<span class="kpi-icn i-indigo" style="width:36px;height:36px;border-radius:9px">${UI.icon('briefcase')}</span>
<div class="lr-main"><div class="lr-title">${j.title}</div><div class="lr-sub">${j.applications} applications</div></div>${UI.badge(j.status)}</div>`).join('') : '<p class="text-muted">No requisitions</p>'}</div>`;
UI.modal({ title: 'Hiring Manager', subtitle: m.id, body, size: 'modal-lg', footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.toast('Message sent','success')">${UI.icon('mail')} Message</button>` });
};
// ---------------- 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 => `<div class="cal-dow">${d}</div>`).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 => `<div class="cal-event ${evColors[iv.type] || 'b-blue'}" onclick="Candidates.openProfile('${iv.candidateId}')" title="${iv.candidate} · ${iv.type}">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} ${iv.candidate.split(' ')[0]}</div>`).join('');
if (dayEvents.length > 3) evs += `<div class="cal-event b-gray">+${dayEvents.length - 3} more</div>`;
}
const isToday = !c.other && c.date && c.date.toDateString() === today.toDateString();
html += `<div class="cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}"><div class="cal-date">${c.day}</div>${evs}</div>`;
});
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 = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Calendar</h1><p class="page-sub">Interview schedule at a glance</p></div>
<div class="page-head-actions">
<div class="flex items-center gap-8">
<button class="btn btn-icon btn-secondary" id="calPrev">${UI.icon('chevron-left')}</button>
<span class="fw-600" id="calMonth" style="min-width:140px;text-align:center">${monthName}</span>
<button class="btn btn-icon btn-secondary" id="calNext">${UI.icon('chevron-right')}</button>
</div>
<button class="btn btn-primary" onclick="Interviews.schedule()">${UI.icon('plus')} Schedule</button>
</div>
</div>
<div class="grid g-2-1">
<div class="card"><div class="card-body"><div class="cal-grid" id="calGrid">${build()}</div></div></div>
<div class="card" style="align-self:start"><div class="card-head"><div><h3>Today</h3><span class="ch-sub">July 9, 2026</span></div></div>
<div class="card-body"><div class="list-tight">${todayIvs.length ? todayIvs.map(iv => `
<div class="list-row" style="cursor:pointer" onclick="Candidates.openProfile('${iv.candidateId}')">${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
<div class="lr-main"><div class="lr-title">${iv.candidate}</div><div class="lr-sub">${iv.type}</div></div>
<div class="lr-right"><div class="fw-600 text-sm">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div></div></div>`).join('') : '<p class="text-muted">No interviews today</p>'}</div></div>
</div>
</div>
</div>`;
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) => `
<div class="notif-row ${n.unread ? 'unread' : ''}" onclick="this.classList.remove('unread')">
<span class="notif-icn ${n.color}">${UI.icon(n.icon)}</span>
<div class="notif-body"><div class="notif-title">${n.title}</div><div class="notif-text">${n.text}</div><div class="notif-time">${n.time}</div></div>
${n.unread ? '<span class="dot dot-blue" style="position:static;border:none;align-self:center"></span>' : ''}
</div>`).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Notifications</h1><p class="page-sub">Stay on top of hiring activity</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="App.markAllNotifsRead();Router.reload()">${UI.icon('check')} Mark all read</button>
<button class="btn btn-ghost" onclick="UI.toast('Notification settings','info')">${UI.icon('more')}</button>
</div>
</div>
<div class="card"><div class="list-tight" style="padding:0">${rows}</div></div>
</div>`;
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 (0100) 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 = `
<div class="page">
<div class="page-head"><div><h1 class="page-title">Help Center</h1><p class="page-sub">Find answers and get support</p></div></div>
<div class="card brand-hero mb-18">
<div class="card-body" style="padding:32px;text-align:center">
<h2 style="font-size:22px;margin-bottom:8px">How can we help you?</h2>
<p style="opacity:.85;margin-bottom:18px">Search our knowledge base or browse the topics below</p>
<div class="topbar-search" style="max-width:480px;margin:0 auto">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input placeholder="Search help articles…" />
</div>
</div>
</div>
<div class="grid g-kpi mb-18">
${resources.map(r => `<div class="card" style="cursor:pointer" onclick="UI.toast('Opening ${r.t}','info')"><div class="card-body" style="text-align:center">
<span class="kpi-icn ${r.cls}" style="margin:0 auto 12px;width:48px;height:48px;border-radius:14px">${UI.icon(r.icn)}</span>
<div class="fw-600">${r.t}</div><div class="lr-sub" style="margin-top:4px">${r.d}</div></div></div>`).join('')}
</div>
<div class="card">
<div class="card-head"><div><h3>Frequently Asked Questions</h3></div></div>
<div class="card-body"><div id="faqList">
${faqs.map((f, i) => `<div class="setting-row" style="cursor:pointer;flex-direction:column;align-items:stretch" onclick="Views._toggleFaq(${i})">
<div class="flex items-center" style="justify-content:space-between"><h4>${f.q}</h4><span id="faqChev${i}" style="color:var(--text-3);transition:.2s">${UI.icon('chevron-right')}</span></div>
<p id="faqA${i}" style="display:none;margin-top:10px">${f.a}</p></div>`).join('')}
</div></div>
</div>
</div>`;
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)';
};

View File

@ -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 => `<div class="user-cell">${UI.avatar(o.candidate, o.initials, o.color)}<div><div class="cell-primary">${o.candidate}</div><div class="cell-sub">${o.jobTitle}</div></div></div>` },
{ key: 'department', label: 'Department', sortable: true },
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: o => `<b>${DB.money(o.base)}</b>` },
{ key: 'equity', label: 'Equity', render: o => `<span class="text-muted">${o.equity}</span>` },
{ key: 'bonus', label: 'Bonus', align: 'center', render: o => `<span class="text-muted">${o.bonus}</span>` },
{ key: 'sent', label: 'Sent', sortable: true, sortValue: o => o.sent.getTime(), render: o => `<span class="text-muted">${DB.fmtShort(o.sent)}</span>` },
{ key: 'status', label: 'Status', sortable: true, render: o => UI.badge(o.status) },
{ key: '_a', label: 'Actions', align: 'right', render: o => `
<div class="row-actions">
<button class="act-btn" data-tip="View" onclick="Offers.view('${o.id}')">${UI.icon('eye')}</button>
<button class="act-btn" data-tip="Resend" onclick="UI.toast('Offer resent to ${o.candidate}','info')">${UI.icon('send')}</button>
</div>` }
]
});
const statusOpts = ['<option value="">All Status</option>'].concat(['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map(s => `<option>${s}</option>`)).join('');
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Offers</h1><p class="page-sub">Track offer letters and acceptance</p></div>
<div class="page-head-actions"><button class="btn btn-primary" onclick="Offers.create()">${UI.icon('plus')} Create Offer</button></div>
</div>
<div class="grid g-kpi mb-18">
${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')}
</div>
<div class="card">
<div class="card-body" style="padding-bottom:0">
<div class="toolbar">
<div class="toolbar-search">${UI.icon('search')}<input id="ofSearch" placeholder="Search candidate or role…"/></div>
<select class="select" id="ofStatus">${statusOpts}</select>
</div>
</div>
${table.html}
</div>
</div>`;
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 = `
<div class="flex items-center gap-12 mb-18">${UI.avatar(o.candidate, o.initials, o.color, 'avatar-lg')}
<div><div class="ph-name" style="font-size:17px">${o.candidate}</div><div class="ph-role">${o.jobTitle} · ${o.department}</div></div>
<div style="margin-left:auto">${UI.badge(o.status)}</div></div>
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-bottom:18px"><div class="card-body">
<div class="form-section-title" style="margin-top:0">Compensation Package</div>
<div class="info-grid">
<div class="info-item"><div class="il">Base Salary</div><div class="iv" style="font-size:18px">${DB.money(o.base)}</div></div>
<div class="info-item"><div class="il">Annual Bonus</div><div class="iv" style="font-size:18px">${o.bonus}</div></div>
<div class="info-item"><div class="il">Equity</div><div class="iv" style="font-size:18px">${o.equity}</div></div>
<div class="info-item"><div class="il">Est. Total Cash</div><div class="iv" style="font-size:18px;color:var(--success)">${DB.money(total)}</div></div>
</div>
</div></div>
<div class="info-grid">
<div class="info-item"><div class="il">Sent On</div><div class="iv">${DB.fmtDate(o.sent)}</div></div>
<div class="info-item"><div class="il">Expires</div><div class="iv">${DB.fmtDate(o.expires)}</div></div>
<div class="info-item"><div class="il">Recruiter</div><div class="iv">${o.recruiter}</div></div>
<div class="info-item"><div class="il">Offer ID</div><div class="iv mono">${o.id}</div></div>
</div>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
<button class="btn btn-secondary" onclick="UI.toast('Offer PDF downloaded','info')">${UI.icon('download')} Download</button>
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Offer resent','success')">${UI.icon('send')} Resend Offer</button>`;
UI.modal({ title: 'Offer Details', subtitle: o.id, body, footer, size: 'modal-lg' });
};
Offers.create = function () {
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
const body = `<form id="offerForm" novalidate><div class="form-grid">
<div class="form-field col-span-2"><label>Candidate <span class="req">*</span></label><select name="candidate">${opt(DB.candidates.filter(c => ['Interview', 'Offer'].includes(c.stage)).map(c => c.name))}</select></div>
<div class="form-field"><label>Base Salary ($) <span class="req">*</span></label><input name="base" type="number" placeholder="140000"/><span class="field-error">Required</span></div>
<div class="form-field"><label>Annual Bonus (%)</label><input name="bonus" type="number" value="10"/></div>
<div class="form-field"><label>Equity (RSU)</label><input name="equity" placeholder="20k RSU"/></div>
<div class="form-field"><label>Expiration Date</label><input name="expires" type="date"/></div>
<div class="form-field col-span-2"><label>Notes</label><textarea name="notes" placeholder="Additional details for the offer"></textarea></div>
</div></form>`;
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="Offers._save()">${UI.icon('send')} Send Offer</button>`;
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();
};

View File

@ -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 `<div class="kanban-col" data-stage="${st.name}">
<div class="kanban-col-head"><span class="k-dot" style="background:${st.color}"></span><h4>${st.name}</h4><span class="k-count">${cards.length}</span></div>
<div class="kanban-cards" data-stage="${st.name}">
${cards.map(c => Pipeline._card(c)).join('')}
</div></div>`;
}).join('');
}
const jobOpts = ['<option value="">All Jobs</option>'].concat(DB.jobs.filter(j => j.status === 'Open').map(j => `<option value="${j.id}">${j.title}</option>`)).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Pipeline</h1><p class="page-sub">Drag candidates between stages to update their status</p></div>
<div class="page-head-actions">
<select class="select" id="pipeJob">${jobOpts}</select>
<button class="btn btn-primary" onclick="Candidates.openAdd()">${UI.icon('plus')} Add Candidate</button>
</div>
</div>
<div class="kanban" id="kanban">${columns()}</div>
</div>`;
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 `<div class="k-card" draggable="true" data-id="${c.id}" onclick="Candidates.openProfile('${c.id}')">
<div class="k-card-top">${UI.avatar(c.name, c.initials, c.color)}
<div><div class="kc-name">${c.name}</div><div class="kc-role">${c.currentTitle}</div></div></div>
<div class="kc-role">${c.jobTitle}</div>
<div class="k-tags">${c.skills.slice(0, 3).map(s => `<span class="tag">${s}</span>`).join('')}</div>
<div class="k-card-meta"><span class="cell-sub">${c.currentCompany}</span>${UI.scoreChip(c.aiScore)}</div>
</div>`;
};
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 = `<div class="empty-state" style="grid-column:1/-1">${UI.icon('search')}<h3>No talent found</h3></div>`; return; }
grid.innerHTML = list.map(c => `
<div class="card" style="cursor:pointer" onclick="Candidates.openProfile('${c.id}')">
<div class="card-body">
<div class="flex items-center gap-12" style="margin-bottom:12px">
${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')}
<div style="flex:1;min-width:0"><div class="lr-title">${c.name}</div><div class="lr-sub">${c.currentTitle}</div></div>
${UI.scoreChip(c.aiScore)}
</div>
<div class="k-tags" style="margin-bottom:12px">${c.skills.slice(0, 4).map(s => `<span class="tag">${s}</span>`).join('')}</div>
<div class="divider" style="margin:12px 0"></div>
<div class="flex items-center" style="justify-content:space-between">
<span class="cell-sub">${UI.icon('briefcase')} ${c.experience} yrs</span>
<span class="cell-sub">${c.currentCompany}</span>
${UI.badge(c.source, 'b-gray')}
</div>
</div>
</div>`).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 = ['<option value="">All Departments</option>'].concat(DB.departments.map(d => `<option>${d}</option>`)).join('');
const html = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Talent Pool</h1><p class="page-sub">${pool.length} silver-medalists & passive candidates to re-engage</p></div>
<div class="page-head-actions"><button class="btn btn-primary" onclick="UI.toast('Talent campaign created','success')">${UI.icon('send')} Start Campaign</button></div>
</div>
<div class="card mb-18"><div class="card-body" style="padding:16px">
<div class="toolbar" style="margin-bottom:0">
<div class="toolbar-search">${UI.icon('search')}<input id="poolSearch" placeholder="Search by name, skill, company…"/></div>
<select class="select" id="poolDept">${deptOpts}</select>
</div>
</div></div>
<div class="grid g-3" id="poolGrid"></div>
</div>`;
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(); };
}
};
};

View File

@ -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 = `
<div class="page">
<div class="page-head">
<div><h1 class="page-title">Access Control</h1><p class="page-sub">Enterprise RBAC configure permissions for every role and module</p></div>
<div class="page-head-actions">
<button class="btn btn-secondary" onclick="RBAC.addRole()">${UI.icon('plus')} New Role</button>
<button class="btn btn-primary" onclick="UI.toast('Permission changes saved','success')">${UI.icon('check')} Save Changes</button>
</div>
</div>
<div class="rbac-layout">
<div class="card" style="align-self:start"><div class="card-body" style="padding:12px">
<div class="nav-section-label" style="padding:6px 8px">Roles</div>
<div class="role-list" id="roleList"></div>
</div></div>
<div class="card"><div id="rbacDetail"></div></div>
</div>
</div>`;
return {
html,
onMount() { RBAC._renderRoles(); RBAC._renderDetail(); }
};
};
RBAC._renderRoles = function () {
const el = document.getElementById('roleList');
el.innerHTML = DB.rbacRoles.map((r, i) => `
<div class="role-item ${i === RBAC._state.roleIdx ? 'active' : ''}" data-idx="${i}">
<span class="role-badge" style="background:${r.color}">${UI.icon('shield')}</span>
<div style="flex:1;min-width:0"><div class="fw-600 text-sm">${r.name}</div><div class="cell-sub">${r.users} user${r.users === 1 ? '' : 's'}</div></div>
</div>`).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 => `
<tr>
<td>${mod}</td>
${DB.permTypes.map((pt, pi) => `<td><span class="perm-check ${r.matrix[mod][pi] ? 'on' : ''}" data-mod="${mod}" data-perm="${pi}">${UI.icon('check')}</span></td>`).join('')}
</tr>`).join('');
el.innerHTML = `
<div class="card-head">
<div class="flex items-center gap-12"><span class="role-badge" style="background:${r.color}">${UI.icon('shield')}</span>
<div><h3>${r.name}</h3><span class="ch-sub">${r.desc}</span></div></div>
<div class="flex items-center gap-8">
<span class="badge b-gray badge-plain">${r.users} users</span>
<button class="btn btn-ghost btn-sm" onclick="RBAC.toggleAll(true)">Grant all</button>
<button class="btn btn-ghost btn-sm" onclick="RBAC.toggleAll(false)">Revoke all</button>
</div>
</div>
<div class="card-body">
<div class="table-wrap"><table class="rbac-matrix">
<thead><tr><th>Module</th>${DB.permTypes.map(p => `<th>${p}</th>`).join('')}</tr></thead>
<tbody>${matrixRows}</tbody>
</table></div>
</div>`;
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: `<form id="roleForm"><div class="form-grid">
<div class="form-field col-span-2"><label>Role Name <span class="req">*</span></label><input name="name" placeholder="e.g. Regional Recruiter"/><span class="field-error">Required</span></div>
<div class="form-field col-span-2"><label>Description</label><input name="desc" placeholder="What can this role do?"/></div>
<div class="form-field"><label>Base Template</label><select name="template"><option>View only</option><option>Editor</option><option>Approver</option><option>Manager</option><option>Administrator</option></select></div>
<div class="form-field"><label>Color</label><select name="color"><option value="var(--av-1)">Utopia Green</option><option value="var(--av-3)">Periwinkle</option><option value="var(--av-6)">Teal</option><option value="var(--av-7)">Violet</option><option value="var(--av-8)">Rose</option></select></div>
</div></form>`,
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="RBAC._saveRole()">${UI.icon('check')} Create Role</button>`
});
};
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');
};

Some files were not shown because too many files have changed in this diff Show More