Compare commits

..

5 Commits

36 changed files with 503 additions and 1259 deletions

View File

@ -45,3 +45,98 @@ tmp/
temp/ temp/
tests/** tests/**
/backend/tests/** /backend/tests/**
migrations/** */
.DS_Store
.AppleDouble
.LSOverride
Icon
?
._*
dist/**
dist/**/*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
**pycache__/
**pycache**
# Claude / local AI tooling
.claude/
.audit.js
# Local macOS launcher (not shared — machine-specific)
Start.command
start.command
# Backups
.backup-prebrand/
*.bak
*.backup
*/node_modules/*
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.venv/
venv/
env/
# Environment / secrets — NEVER commit real .env files
.env
**/.env
.env.*
**/.env.*
!.env.example
!**/.env.example
!frontend/.env.development
!frontend/.env.production
# Postman/Insomnia environments holding real API keys
*.postman_environment.local.json
# Logs & temp
*.log
tmp/
temp/
.cache/
# Frontend build
node_modules/
frontend/dist/
# Uploaded content — user data, never in git
backend/uploads/
# Google OAuth ADC / Desktop client secrets — never commit
backend/credentials/*.json
**.pdf
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
# also swallowed any module with two underscores (e.g. test_talent_plugins.py).
backend/migrations/versions/**_**_**.py
Utopia-ai-hr-ats-portal 1.pem
# Local-only Compose overrides (never deployed)
docker.local.env
**/.env**
# Paper form source documents (Annexure A/E/J) — reference material, not code.
# Root-anchored: backend/candidate_forms/ is the forms domain package and IS tracked.
/candidate_forms/
frontend/dist/** */
docker.local.frontend/dist/** */
frontend/dist/index.html
frontend/dist/index.html
# `tests/**` and `/backend/tests/**` used to sit here. Both test suites are
# tracked and both are run by scripts/ci-checks.sh, so the rules were inert for
# the files that already existed and did nothing but silently swallow NEW ones:
# a test added to either suite never showed up in `git status`, and CI ran a
# suite that did not include it. Removed rather than negated, because there is
# nothing under either path that should be ignored.
frontend/dist/**
nginx.conf
smoke.test.mjs

1
.gitignore vendored
View File

@ -93,4 +93,3 @@ frontend/dist/index.html
frontend/dist/** frontend/dist/**
nginx.conf nginx.conf
smoke.test.mjs smoke.test.mjs
Annex**

View File

@ -21,3 +21,98 @@ htmlcov/
samples/ samples/
*.pdf *.pdf
!tests/**/fixtures/*.pdf !tests/**/fixtures/*.pdf
migrations/** */
.DS_Store
.AppleDouble
.LSOverride
Icon
?
._*
dist/**
dist/**/*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
**pycache__/
**pycache**
# Claude / local AI tooling
.claude/
.audit.js
# Local macOS launcher (not shared — machine-specific)
Start.command
start.command
# Backups
.backup-prebrand/
*.bak
*.backup
*/node_modules/*
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.venv/
venv/
env/
# Environment / secrets — NEVER commit real .env files
.env
**/.env
.env.*
**/.env.*
!.env.example
!**/.env.example
!frontend/.env.development
!frontend/.env.production
# Postman/Insomnia environments holding real API keys
*.postman_environment.local.json
# Logs & temp
*.log
tmp/
temp/
.cache/
# Frontend build
node_modules/
frontend/dist/
# Uploaded content — user data, never in git
backend/uploads/
# Google OAuth ADC / Desktop client secrets — never commit
backend/credentials/*.json
**.pdf
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
# also swallowed any module with two underscores (e.g. test_talent_plugins.py).
backend/migrations/versions/**_**_**.py
Utopia-ai-hr-ats-portal 1.pem
# Local-only Compose overrides (never deployed)
docker.local.env
**/.env**
# Paper form source documents (Annexure A/E/J) — reference material, not code.
# Root-anchored: backend/candidate_forms/ is the forms domain package and IS tracked.
/candidate_forms/
frontend/dist/** */
docker.local.frontend/dist/** */
frontend/dist/index.html
frontend/dist/index.html
# `tests/**` and `/backend/tests/**` used to sit here. Both test suites are
# tracked and both are run by scripts/ci-checks.sh, so the rules were inert for
# the files that already existed and did nothing but silently swallow NEW ones:
# a test added to either suite never showed up in `git status`, and CI ran a
# suite that did not include it. Removed rather than negated, because there is
# nothing under either path that should be ignored.
frontend/dist/**
nginx.conf
smoke.test.mjs

View File

@ -1,654 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="./vendor/react.js"></script>
<script src="./vendor/react-dom.js"></script>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
:root{
--bg:#03171d; --bg-elev:#071e26; --bg-sunken:#0c2933; --border:#1b404b; --border-strong:#315764;
--text:#edf7fa; --text-2:#b6ced7; --text-3:#9ebbc6;
--primary:#ccfa70; --primary-fg:#14210b; --primary-soft:#ccfa7012;
--success:#25e9a5; --success-soft:rgba(37,233,165,.12);
--warning:#ffd16e; --warning-soft:rgba(255,209,110,.14);
--danger:#ff7c86; --danger-soft:rgba(255,124,134,.14);
--info:#82bcff; --info-soft:rgba(130,188,255,.14);
--purple:#b6a6ff; --purple-soft:rgba(182,166,255,.14);
--teal:#25e9a5; --teal-soft:rgba(37,233,165,.12);
}
*{box-sizing:border-box;}
a{color:inherit;text-decoration:none;}
a:hover{color:var(--text);}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;}
input,textarea,select{font:inherit;color:inherit;}
body{margin:0;font-family:'Neue Montreal','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;color-scheme:dark;}
.page-shell{min-height:100%;background:radial-gradient(ellipse at 50% 0,rgba(11,41,48,.3),transparent 58%) var(--bg);}
/* ---------- icons ---------- */
.icon{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;}
.icon-14{width:14px;height:14px;} .icon-15{width:15px;height:15px;} .icon-16{width:16px;height:16px;}
.icon-17{width:17px;height:17px;} .icon-18{width:18px;height:18px;} .icon-20{width:20px;height:20px;} .icon-22{width:22px;height:22px;}
/* ---------- topbar ---------- */
.topbar{display:flex;align-items:center;gap:22px;min-height:67px;padding:12px 42px;background:#03181e;border-bottom:1px solid var(--border);}
.candidate-brand{display:flex;align-items:center;gap:13px;min-width:230px;}
.brand-mark{width:36px;height:36px;fill:#25e9a5;}
.candidate-brand strong{display:block;font-size:18px;line-height:1.25;letter-spacing:-.4px;}
.candidate-brand small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
.menu-toggle{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
.menu-toggle:hover{background:var(--bg-sunken);}
.topbar-search{position:relative;flex:1;max-width:520px;}
.topbar-search input{width:100%;height:38px;padding:0 14px 0 38px;border-radius:8px;background:#0c2832;border:1px solid var(--border);color:var(--text);font-size:13px;}
.topbar-search input::placeholder{color:var(--text-3);}
.search-icn{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-3);}
.topbar-actions{display:flex;align-items:center;gap:12px;margin-left:auto;}
.icon-btn{position:relative;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
.icon-btn:hover{background:var(--bg-sunken);color:var(--text);}
.dot-red{position:absolute;top:7px;right:7px;width:7px;height:7px;border-radius:50%;background:var(--danger);border:2px solid #03181e;}
.topbar-divider{width:1px;height:24px;background:var(--border);}
.profile-btn{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 5px;border-radius:30px;}
.profile-btn:hover{background:var(--bg-sunken);}
.avatar{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;font-weight:600;font-size:13px;color:#071720;flex-shrink:0;}
.avatar-grad{background:#a19df5;}
.profile-meta{display:flex;flex-direction:column;line-height:1.2;text-align:left;}
.profile-name{font-weight:600;font-size:13px;}
.profile-role{font-size:11.5px;color:var(--text-3);}
.chev{color:var(--text-3);}
/* ---------- page shell ---------- */
.content{padding:0 42px 40px;}
.cand-page{max-width:1740px;margin-inline:auto;font-size:13px;}
.cand-page-bar{display:flex;align-items:center;gap:16px;min-height:58px;padding-block:16px;}
.cand-page-crumb{font-size:12px;color:var(--text-3);flex:1;}
.cand-page-crumb strong{color:var(--text);}
.cand-page-crumb span{margin:0 8px;}
/* ---------- buttons / badges ---------- */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:7px 12px;border-radius:7px;font-weight:500;font-size:12px;white-space:nowrap;border:1px solid transparent;transition:.15s;}
.btn-secondary{border:1px solid var(--border);color:var(--text);background:linear-gradient(120deg,#0c2730,#071e26);}
.btn-secondary:hover{background:#13333d;border-color:#39606b;}
.btn-primary{color:var(--primary-fg);border:1px solid #c5ed6e;background:linear-gradient(105deg,#d3fd80,#c9f86b);font-weight:650;}
.btn-primary:hover{background:#dcff9b;}
.btn-sm{min-height:30px;padding:5px 8px;}
.btn:disabled{cursor:not-allowed;opacity:.45;}
.cw-danger{border:1px solid #ae4a55;color:#ff7c86;background:#2a172055;}
.cw-danger:hover:not(:disabled){background:#50232b;}
.star-btn.on{color:var(--primary);border-color:#788e49;}
.badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;white-space:nowrap;}
.badge::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;}
.st-blue{color:var(--info);background:var(--info-soft);}
.st-purple{color:var(--purple);background:var(--purple-soft);}
.st-amber{color:var(--warning);background:var(--warning-soft);}
.st-indigo{color:var(--primary);background:var(--primary-soft);}
.st-teal{color:var(--teal);background:var(--teal-soft);}
.st-green{color:var(--success);background:var(--success-soft);}
.st-red{color:var(--danger);background:var(--danger-soft);}
.st-gray{color:var(--text-2);background:var(--bg-sunken);}
/* ---------- hero ---------- */
.cw-hero{display:flex;gap:24px;padding:22px 24px 20px;border:1px solid var(--border);border-radius:13px;background:linear-gradient(110deg,#09252e,#061d25 70%,#09252c);}
.cw-avatar{width:78px;height:78px;font-size:28px;flex-shrink:0;}
.cw-hero-body,.cw-identity{flex:1;min-width:0;}
.cw-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;}
.cw-name{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px;}
.cw-name h1{margin:0;font-size:28px;line-height:1.2;letter-spacing:-.7px;font-weight:650;}
.cw-contact{display:flex;flex-wrap:wrap;gap:9px 22px;color:var(--text-3);font-size:12px;}
.cw-contact>*{display:inline-flex;align-items:center;gap:8px;}
.cw-external{color:#9dd3f1 !important;text-decoration:underline;text-underline-offset:3px;}
.cw-hero-actions{display:flex;gap:10px;flex-shrink:0;}
.cw-facts{display:grid;grid-template-columns:1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr;margin-top:22px;}
.cw-fact{display:flex;align-items:center;gap:12px;min-width:0;padding:0 16px;border-left:1px solid var(--border);}
.cw-fact:first-child{border-left:0;padding-left:0;}
.cw-fact:last-child{padding-right:0;}
.cw-fact>svg{color:#c3dce4;}
.cw-fact span{display:block;color:var(--text-3);font-size:12px;margin-bottom:4px;}
.cw-fact strong{font-size:13px;font-weight:500;}
/* ---------- tabs ---------- */
.cw-tabs{margin-top:16px;}
.tabs{display:flex;gap:10px;border-bottom:1px solid var(--border);overflow-x:auto;}
.tab{display:inline-flex;align-items:center;gap:8px;min-height:55px;padding:12px 20px;font-size:13px;font-weight:400;color:var(--text-2);border-bottom:3px solid transparent;margin-bottom:-1px;white-space:nowrap;}
.tab:hover{color:var(--text);}
.tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600;}
.tab-count{background:#153941;color:#cbdee4;font-size:11px;min-width:18px;text-align:center;padding:1px 6px;border-radius:20px;}
.tab.active .tab-count{background:var(--primary-soft);color:var(--primary);}
/* ---------- overview grid ---------- */
.cw-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.65fr) minmax(0,.99fr);gap:16px;align-items:start;margin-top:16px;}
.cw-column{display:flex;flex-direction:column;gap:14px;min-width:0;}
.cw-card{min-width:0;padding:18px 17px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(120deg,#09232c,#061e26 90%);}
.cw-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px;}
.cw-card-head h2{font-size:15px;font-weight:650;letter-spacing:-.2px;margin:0;}
.cw-link{display:inline-flex;align-items:center;gap:6px;color:#b4ed91;text-decoration:underline;text-underline-offset:3px;font-size:12px;}
.cw-info{display:grid;gap:15px;margin:0;}
.cw-info>div{display:grid;grid-template-columns:minmax(115px,.9fr) minmax(0,1.4fr);gap:12px;line-height:1.4;font-size:12px;}
.cw-info dt{display:flex;align-items:flex-start;gap:10px;color:var(--text-3);margin:0;}
.cw-info dd{margin:0;}
.cw-skills{display:flex;gap:8px;flex-wrap:wrap;}
.cw-skills>span{padding:6px 10px;border:1px solid #284b57;border-radius:12px;background:#102d38;color:#e0edf3;font-size:12px;}
.cw-table-wrap{overflow:auto;}
.cw-applications{width:100%;border-collapse:collapse;font-size:12px;text-align:left;}
.cw-applications th{color:#bad1dc;text-transform:uppercase;letter-spacing:.4px;font-size:11px;font-weight:500;border-top:1px solid #15343d;border-bottom:1px solid #15343d;padding:9px 6px;white-space:nowrap;}
.cw-applications td{padding:13px 6px;border-bottom:1px solid #15343d;}
.cw-applications td:first-child,.cw-applications th:first-child{padding-left:0;}
.cw-applications td:last-child,.cw-applications th:last-child{padding-right:0;}
.cw-applications tr:last-child td{border-bottom:0;}
.cw-applications td:nth-child(2){color:var(--text-2);white-space:nowrap;}
.cw-applications strong{display:block;font-size:13px;font-weight:550;}
.cw-applications small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
.cw-applications .badge{font-size:11px;padding:3px 7px;}
.cw-applications .is-current{background:linear-gradient(90deg,rgba(18,53,52,.22),transparent);}
.cw-summary{margin:0;color:var(--text-2);font-size:13px;line-height:1.8;}
.cw-empty{color:var(--text-3);font-size:13px;line-height:1.7;margin:0;}
.cw-document{display:flex;align-items:center;gap:11px;padding:10px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(100deg,#0d2c36,#0a232b);}
.cw-document-icon{display:flex;flex-direction:column;align-items:center;justify-content:center;width:29px;height:36px;background:linear-gradient(135deg,#ff7575,#df424d);border-radius:4px;color:#fff;flex-shrink:0;}
.cw-document-icon small{font-size:7px;margin-top:2px;}
.cw-document-name{flex:1;min-width:0;}
.cw-document-name strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500;}
.cw-document-name small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
.cw-document-actions{display:flex;gap:6px;flex-shrink:0;}
.cw-document-list{display:grid;gap:9px;}
.cw-bottom-grid{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:14px;}
.cw-bottom-grid .cw-card{padding:17px;}
.cw-rating{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.cw-rating>span{font-size:12px;color:var(--primary);}
.rating-stars{display:inline-flex;gap:3px;}
.rating-stars .rs{color:var(--border-strong);}
.rating-stars .rs svg{width:18px;height:18px;}
.rating-stars .rs.on{color:var(--warning);}
.rating-stars .rs.on svg{fill:currentColor;}
.cw-recruiter{display:flex;align-items:center;gap:10px;}
.cw-recruiter .avatar{width:32px;height:32px;font-size:12px;}
.cw-recruiter strong{display:block;font-size:12px;font-weight:500;}
.cw-recruiter small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
.cw-action-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;}
.cw-action-grid .btn{font-size:12px;justify-content:flex-start;padding:8px;white-space:normal;text-align:left;}
.cw-active-application{font-size:12px;color:var(--text-3);margin:-4px 0 12px;}
.cw-status-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:10px;}
.cw-field-label{display:block;color:var(--text-3);font-size:12px;margin-bottom:5px;}
.cw-status-grid select,.cw-status-value{width:100%;min-height:37px;padding:8px 10px;background:#0c2933;color:var(--text);border:1px solid var(--border);border-radius:7px;font-size:12px;}
.cw-status-grid select{appearance:none;}
.cw-status-value{display:flex;align-items:center;gap:8px;}
.cw-status-dot{width:7px;height:7px;background:var(--success);border-radius:50%;flex-shrink:0;}
.cw-status-dot.is-closed{background:var(--text-3);}
.cw-activity{list-style:none;margin:0;padding:0;}
.cw-activity li{position:relative;padding:0 0 23px 24px;}
.cw-activity li:last-child{padding-bottom:0;}
.cw-activity li::before{content:'';position:absolute;left:0;top:4px;width:10px;height:10px;background:#59a8ff;border:2px solid #245788;border-radius:50%;}
.cw-activity li:not(:last-child)::after{content:'';position:absolute;width:1px;left:4px;top:15px;bottom:3px;background:#315662;}
.cw-activity-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
.cw-activity strong{font-size:12px;font-weight:550;}
.cw-activity time{color:var(--text-3);font-size:11px;white-space:nowrap;}
.cw-activity p{color:var(--text-3);font-size:12px;line-height:1.65;margin:5px 0 0;}
.cw-activity small{color:var(--text-3);font-size:11px;}
.cw-screening{display:flex;align-items:center;gap:18px;}
.cw-match{display:grid;justify-items:center;gap:6px;flex-shrink:0;}
.cw-match small{color:var(--text-3);font-size:12px;}
.score-ring{--pct:0;position:relative;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(var(--sc-color) calc(var(--pct)*1%),var(--bg-sunken) 0);}
.score-ring::after{content:'';position:absolute;inset:4px;border-radius:50%;background:var(--bg-elev);}
.score-ring span{position:relative;z-index:1;font-size:10px;font-weight:700;}
.cw-tab-content{padding:22px;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;margin-top:16px;}
.cw-muted{color:var(--text-3);}
/* ---------- secondary-tab content (lighter fidelity, same tokens) ---------- */
.simple-row{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--border);}
.simple-row:first-child{border-top:0;padding-top:0;}
.simple-row-icn{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;flex-shrink:0;background:var(--bg-sunken);color:var(--info);}
.simple-row-main{flex:1;min-width:0;}
.simple-row-title{font-size:13px;font-weight:600;}
.simple-row-sub{font-size:12px;color:var(--text-3);margin-top:3px;}
.note-compose textarea{width:100%;min-height:64px;padding:10px 12px;background:#0c2933;border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit;resize:vertical;margin-bottom:10px;}
.note-compose textarea::placeholder{color:var(--text-3);}
.section-label{font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:.4px;margin-bottom:12px;}
</style>
</helmet>
<div class="page-shell">
<header class="topbar">
<a class="candidate-brand" href="#">
<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>
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
</a>
<button class="menu-toggle" aria-label="Toggle menu">
<svg class="icon icon-20" 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 class="icon icon-16 search-icn" 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" placeholder="Search candidates, jobs, requisitions…" />
</div>
<div class="topbar-actions">
<button class="icon-btn" aria-label="Notifications">
<svg class="icon icon-20" 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-red"></span>
</button>
<div class="topbar-divider"></div>
<button class="profile-btn">
<span class="avatar avatar-grad">MK</span>
<span class="profile-meta"><span class="profile-name">Meera Khan</span><span class="profile-role">Recruiter</span></span>
<svg class="icon icon-16 chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</header>
<main class="content">
<div class="cand-page">
<div class="cand-page-bar">
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>Back</button>
<div class="cand-page-crumb">Candidates <span>/</span> <strong>Ada Lovelace</strong></div>
<div class="cand-page-actions">
<button class="btn btn-secondary star-btn {{favClass}}" onClick="{{favoriteToggle}}">
<svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>{{favLabel}}
</button>
</div>
</div>
<!-- ============ HERO ============ -->
<header class="cw-hero">
<span class="avatar cw-avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">AL</span>
<div class="cw-hero-body">
<div class="cw-hero-top">
<div class="cw-identity">
<div class="cw-name">
<h1>Ada Lovelace</h1>
<span class="badge {{stageClass}}">{{stage}}</span>
</div>
<div class="cw-contact">
<a href="mailto:ada.lovelace@example.com"><svg class="icon icon-16" 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>ada.lovelace@example.com</a>
<a href="tel:+15552147788"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>+1 (555) 214-7788</a>
<span><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>Austin, TX</span>
<a class="cw-external" href="#" target="_blank" rel="noopener noreferrer"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>LinkedIn profile</a>
</div>
</div>
<div class="cw-hero-actions">
<button class="btn btn-secondary"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>Download CV</button>
<button class="btn btn-primary"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>Open Resume</button>
</div>
</div>
<div class="cw-facts">
<div class="cw-fact"><svg class="icon icon-20" 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><div><span>Applied for</span><strong>Senior Backend Engineer</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" 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><div><span>Applied on</span><strong>Mar 12, 2026</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg><div><span>Source</span><strong>Careers page</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" 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><div><span>Current company</span><strong>Meridian Systems</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><div><span>Experience</span><strong>6 years</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg><div><span>Education</span><strong>MSc Computer Science</strong></div></div>
<div class="cw-fact"><svg class="icon icon-20" 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"/><polyline points="14 2 14 8 20 8"/></svg><div><span>Total applications</span><strong>{{appCount}}</strong></div></div>
</div>
</div>
</header>
<!-- ============ TABS ============ -->
<div class="cw-tabs">
<div class="tabs">
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="8">
<button class="{{t.cls}}" onClick="{{t.pick}}">{{t.label}}<sc-if value="{{t.hasCount}}" hint-placeholder-val="{{true}}"><span class="tab-count">{{t.count}}</span></sc-if></button>
</sc-for>
</div>
</div>
<!-- ============ OVERVIEW ============ -->
<sc-if value="{{showOverview}}" hint-placeholder-val="{{true}}">
<div class="cw-overview">
<div class="cw-column">
<section class="cw-card">
<div class="cw-card-head"><h2>Candidate Information</h2></div>
<dl class="cw-info">
<div><dt><svg class="icon icon-15" 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>Full name</dt><dd>Ada Lovelace</dd></div>
<div><dt><svg class="icon icon-15" 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>Email</dt><dd>ada.lovelace@example.com</dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><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"/></svg>Phone</dt><dd>+1 (555) 214-7788</dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><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"/></svg>Location</dt><dd>Austin, TX</dd></div>
<div><dt><svg class="icon icon-15" 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>Current company</dt><dd>Meridian Systems</dd></div>
<div><dt><svg class="icon icon-15" 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>Current title</dt><dd>Senior Backend Engineer</dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Experience</dt><dd>6 years</dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>Education</dt><dd>MSc Computer Science — Imperial College London</dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><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"/></svg>LinkedIn</dt><dd><a class="cw-external" href="#" target="_blank" rel="noopener noreferrer">View LinkedIn profile</a></dd></div>
<div><dt><svg class="icon icon-15" 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>Notice period</dt><dd><sc-if value="{{hasNoticePeriod}}" hint-placeholder-val="{{true}}">{{noticePeriod}}</sc-if><sc-if value="{{noNoticePeriod}}" hint-placeholder-val="{{false}}"><span class="cw-muted"></span></sc-if></dd></div>
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><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"/></svg>Expected salary</dt><dd><sc-if value="{{hasExpectedSalary}}" hint-placeholder-val="{{true}}">{{expectedSalary}}</sc-if><sc-if value="{{noExpectedSalary}}" hint-placeholder-val="{{false}}"><span class="cw-muted"></span></sc-if></dd></div>
</dl>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Skills &amp; Tags</h2></div>
<div class="cw-skills">
<span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Docker</span><span>Kubernetes</span><span>REST APIs</span><span>Kafka</span><span>AWS</span>
</div>
</section>
</div>
<div class="cw-column">
<section class="cw-card">
<div class="cw-card-head">
<h2>Applications ({{appCount}})</h2>
<sc-if value="{{hasMoreApps}}" hint-placeholder-val="{{true}}">
<button class="cw-link" onClick="{{toggleApps}}"><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>{{appsToggleLabel}}</button>
</sc-if>
</div>
<div class="cw-table-wrap">
<table class="cw-applications">
<thead><tr><th>Job title</th><th>Applied on</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
<sc-for list="{{applications}}" as="a" hint-placeholder-count="3">
<tr class="{{a.rowCls}}">
<td><strong>{{a.title}}</strong><small>{{a.sub}}</small></td>
<td>{{a.when}}</td>
<td><span class="badge {{a.cls}}">{{a.status}}</span></td>
<td><sc-if value="{{a.current}}" hint-placeholder-val="{{false}}"><span class="cw-muted"></span></sc-if><sc-if value="{{a.notCurrent}}" hint-placeholder-val="{{true}}"><button class="btn btn-secondary btn-sm">View</button></sc-if></td>
</tr>
</sc-for>
</tbody>
</table>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Professional Summary</h2></div>
<p class="cw-summary">Senior backend engineer with 6 years building high-throughput payment and fulfillment services. Led the migration of a monolith to event-driven microservices on Kafka, cutting checkout latency by 40%. Comfortable owning a service from design through on-call.</p>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Resume</h2></div>
<div class="cw-document">
<span class="cw-document-icon"><svg class="icon icon-16" 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"/><polyline points="14 2 14 8 20 8"/></svg><small>PDF</small></span>
<div class="cw-document-name"><strong title="Ada_Lovelace_Resume.pdf">Ada_Lovelace_Resume.pdf</strong><small>PDF · Mar 12, 2026</small></div>
<div class="cw-document-actions">
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>Preview</button>
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><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"/></svg>Download</button>
</div>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Ratings</h2></div>
<div class="cw-rating" role="radiogroup" aria-label="Candidate rating">
<div class="rating-stars">
<sc-for list="{{stars}}" as="star" hint-placeholder-count="5">
<span class="{{star.cls}}" onClick="{{star.pick}}"><svg class="icon" viewBox="0 0 24 24"><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"/></svg></span>
</sc-for>
</div>
<span>{{ratingText}}</span>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Recruiter</h2></div>
<div class="cw-recruiter">
<span class="avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">MK</span>
<div><strong>Meera Khan</strong><small>Hiring team</small></div>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>AI Screening</h2></div>
<div class="cw-screening">
<div class="cw-match">
<span class="score-ring" style="--pct:82;--sc-color:var(--warning)"><span>82</span></span>
<small>Strong Match</small>
</div>
<div class="cw-summary"><p>Meets every mandatory requirement with demonstrated production experience; missing only the Kubernetes depth the role prefers.</p></div>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Suggested Roles</h2></div>
<div class="cw-skills"><span>Platform Engineer</span><span>Staff Backend Engineer</span></div>
</section>
</div>
<aside class="cw-column" aria-label="Candidate actions and activity">
<section class="cw-card">
<div class="cw-card-head"><h2>Quick Actions</h2></div>
<div class="cw-action-grid">
<button class="btn btn-primary"><svg class="icon icon-16" 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>Schedule Interview</button>
<button class="btn btn-secondary" onClick="{{moveNext}}"><svg class="icon icon-16" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>Move to {{nextStageLabel}}</button>
<button class="btn btn-secondary" onClick="{{goNotes}}"><svg class="icon icon-16" 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>Add Note</button>
<button class="btn btn-secondary" onClick="{{goForms}}"><svg class="icon icon-16" 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"/><polyline points="14 2 14 8 20 8"/></svg>View Forms</button>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Status &amp; Stage</h2></div>
<p class="cw-active-application">Active application: Senior Backend Engineer</p>
<div class="cw-status-grid">
<div>
<span class="cw-field-label">Status</span>
<div class="cw-status-value"><span class="cw-status-dot {{statusDotCls}}"></span>{{statusLabel}}</div>
</div>
<div>
<span class="cw-field-label">Stage</span>
<div class="cw-status-value">{{stage}}</div>
</div>
</div>
</section>
<section class="cw-card">
<div class="cw-card-head"><h2>Recent Activity</h2><button class="cw-link" onClick="{{goTimeline}}"><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>View all</button></div>
<ol class="cw-activity">
<li><div class="cw-activity-top"><strong>Interview scheduled</strong><time>Mar 15, 2026</time></div><p>Technical round with hiring panel</p></li>
<li><div class="cw-activity-top"><strong>Internal note added</strong><time>Mar 14, 2026</time></div><p>Great communication, prior fintech experience.</p><small>By Meera Khan</small></li>
<li><div class="cw-activity-top"><strong>Screening completed</strong><time>Mar 13, 2026</time></div><p>Match score: 82% — strong technical alignment</p></li>
<li><div class="cw-activity-top"><strong>Application received</strong><time>Mar 12, 2026</time></div><p>Applied via Careers page</p></li>
</ol>
</section>
</aside>
</div>
</sc-if>
<!-- ============ SECONDARY TABS ============ -->
<sc-if value="{{showResume}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="cw-document">
<span class="cw-document-icon"><svg class="icon icon-16" 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"/><polyline points="14 2 14 8 20 8"/></svg><small>PDF</small></span>
<div class="cw-document-name"><strong>Ada_Lovelace_Resume.pdf</strong><small>Original CV from the application</small></div>
<div class="cw-document-actions"><button class="btn btn-secondary btn-sm">Preview</button><button class="btn btn-secondary btn-sm">Download</button></div>
</div>
</div>
</sc-if>
<sc-if value="{{showInterview}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="simple-row">
<span class="simple-row-icn"><svg class="icon icon-18" 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>
<div class="simple-row-main"><div class="simple-row-title">Technical — System Design</div><div class="simple-row-sub">Mar 15, 2026 · 3:00 PM</div></div>
<span class="badge st-blue">Scheduled</span>
</div>
<p class="cw-empty" style="margin-top:16px">Scheduling a new round attaches it to this application.</p>
<button class="btn btn-primary btn-sm" style="margin-top:10px"><svg class="icon icon-16" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Schedule Interview</button>
</div>
</sc-if>
<sc-if value="{{showForms}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="simple-row">
<span class="simple-row-icn"><svg class="icon icon-18" 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"/><polyline points="14 2 14 8 20 8"/></svg></span>
<div class="simple-row-main"><div class="simple-row-title">Technical scorecard</div><div class="simple-row-sub">Submitted by Farhan Ali · Mar 15, 2026</div></div>
<span class="badge st-green">Submitted</span>
</div>
<div class="simple-row">
<span class="simple-row-icn"><svg class="icon icon-18" 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"/><polyline points="14 2 14 8 20 8"/></svg></span>
<div class="simple-row-main"><div class="simple-row-title">Offer approval</div><div class="simple-row-sub">Pending hiring manager sign-off</div></div>
<span class="badge st-amber">Pending</span>
</div>
</div>
</sc-if>
<sc-if value="{{showNotes}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="note-compose">
<textarea placeholder="Write a private note about this candidate…" value="{{noteDraft}}" onChange="{{onNoteDraftChange}}"></textarea>
<button class="btn btn-primary btn-sm" onClick="{{addNote}}">Add Note</button>
</div>
<div style="margin-top:18px">
<sc-for list="{{notes}}" as="n" hint-placeholder-count="2">
<div class="simple-row">
<span class="avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">{{n.initials}}</span>
<div class="simple-row-main"><div class="simple-row-title">{{n.author}}</div><div class="simple-row-sub">{{n.text}}</div><div class="simple-row-sub">{{n.when}}</div></div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{showActivity}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><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"/></svg></span><div class="simple-row-main"><div class="simple-row-sub">Profile viewed by Meera Khan</div><div class="simple-row-sub">1h ago</div></div></div>
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" 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></span><div class="simple-row-main"><div class="simple-row-sub">Email sent: Interview invitation</div><div class="simple-row-sub">1 day ago</div></div></div>
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><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"/></svg></span><div class="simple-row-main"><div class="simple-row-sub">Assessment score updated to 82%</div><div class="simple-row-sub">2 days ago</div></div></div>
</div>
</sc-if>
<sc-if value="{{showTimeline}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<ol class="cw-activity">
<li><div class="cw-activity-top"><strong>Application received</strong><time>Mar 12, 2026</time></div><p>Applied via Careers page</p></li>
<li><div class="cw-activity-top"><strong>AI screening completed</strong><time>Mar 13, 2026</time></div><p>Match score: 82%</p></li>
<li><div class="cw-activity-top"><strong>Interview scheduled</strong><time>Mar 15, 2026</time></div><p>Technical — System Design</p></li>
</ol>
</div>
</sc-if>
<sc-if value="{{showHistory}}" hint-placeholder-val="{{false}}">
<div class="cw-tab-content">
<div class="section-label">Today</div>
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></span><div class="simple-row-main"><div class="simple-row-title">Stage changed</div><div class="simple-row-sub">Screening → Interview</div><div class="simple-row-sub">Meera Khan · 10:14 AM</div></div></div>
<div class="simple-row"><span class="simple-row-icn"><svg class="icon icon-18" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg></span><div class="simple-row-main"><div class="simple-row-title">Feedback submitted</div><div class="simple-row-sub">by Farhan Ali</div></div></div>
</div>
</sc-if>
</div>
</main>
</div>
</x-dc>
<script data-dc-script>
class Component extends DCLogic {
constructor(props) {
super(props);
this.state = {
tab: 'Overview',
rating: 4,
stage: 'Interview',
favorite: true,
appsExpanded: false,
noteDraft: '',
noticePeriod: '4 weeks',
expectedSalary: '$168,000',
notes: [
{ id: 1, author: 'Meera Khan', initials: 'MK', text: 'Great communication, prior fintech experience.', when: 'Mar 14, 2026' },
{ id: 2, author: 'Farhan Ali', initials: 'FA', text: 'Strong system design answers in the technical screen.', when: 'Mar 10, 2026' },
],
};
this.selectTab = this.selectTab.bind(this);
this.setRating = this.setRating.bind(this);
this.toggleFavorite = this.toggleFavorite.bind(this);
this.toggleApps = this.toggleApps.bind(this);
this.moveNext = this.moveNext.bind(this);
this.addNote = this.addNote.bind(this);
}
selectTab(key) { this.setState({ tab: key }); }
setRating(n) { this.setState({ rating: n }); }
toggleFavorite() { this.setState({ favorite: !this.state.favorite }); }
toggleApps() { this.setState({ appsExpanded: !this.state.appsExpanded }); }
moveNext() {
const order = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'];
const i = order.indexOf(this.state.stage);
if (i >= 0 && i < order.length - 1) this.setState({ stage: order[i + 1] });
}
addNote() {
const text = this.state.noteDraft.trim();
if (!text) return;
const note = { id: Date.now(), author: 'You', initials: 'Y', text, when: 'Just now' };
this.setState({ notes: [note, ...this.state.notes], noteDraft: '' });
}
renderVals() {
const s = this.state;
const KANBAN = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'];
const STATUS_CLASS = {
Shortlist: 'st-blue', Screening: 'st-purple', Assessment: 'st-amber', Interview: 'st-indigo',
Offer: 'st-teal', Approved: 'st-gray', Hired: 'st-green', Rejected: 'st-red', 'On Hold': 'st-amber',
};
const idx = KANBAN.indexOf(s.stage);
const nextStage = idx >= 0 && idx < KANBAN.length - 1 ? KANBAN[idx + 1] : null;
const isClosed = s.stage === 'Rejected' || s.stage === 'Hired';
const statusLabel = isClosed ? 'Closed' : s.stage === 'On Hold' ? 'On hold' : 'In progress';
const tabDefs = [
{ key: 'Overview', label: 'Overview', count: null },
{ key: 'Resume', label: 'Resume', count: null },
{ key: 'Interview', label: 'Interviews', count: 1 },
{ key: 'Forms', label: 'Forms', count: 2 },
{ key: 'Notes', label: 'Notes', count: s.notes.length },
{ key: 'Activity', label: 'Activity', count: 5 },
{ key: 'Timeline', label: 'Timeline', count: null },
{ key: 'History', label: 'History', count: null },
];
const tabs = tabDefs.map((t) => ({
...t,
cls: t.key === s.tab ? 'tab active' : 'tab',
hasCount: t.count != null,
pick: () => this.selectTab(t.key),
}));
const allApplications = [
{ title: 'Senior Backend Engineer', sub: 'Current application', when: 'Mar 12, 2026', status: s.stage, cls: STATUS_CLASS[s.stage] || 'st-gray', current: true },
{ title: 'Backend Engineer', sub: 'Email application', when: 'Jan 5, 2025', status: 'Rejected', cls: 'st-red', current: false },
{ title: 'Platform Engineer II', sub: 'Email application', when: 'Aug 22, 2024', status: 'Rejected', cls: 'st-red', current: false },
{ title: 'Backend Engineer Intern', sub: 'Application form', when: 'Jun 3, 2022', status: 'Hired', cls: 'st-green', current: false },
].map((a) => ({ ...a, rowCls: a.current ? 'is-current' : '', notCurrent: !a.current }));
const applications = s.appsExpanded ? allApplications : allApplications.slice(0, 3);
const stars = [1, 2, 3, 4, 5].map((n) => ({
n, cls: n <= s.rating ? 'rs on' : 'rs', pick: () => this.setRating(n),
}));
return {
tab: s.tab, tabs,
showOverview: s.tab === 'Overview',
showResume: s.tab === 'Resume',
showInterview: s.tab === 'Interview',
showForms: s.tab === 'Forms',
showNotes: s.tab === 'Notes',
showActivity: s.tab === 'Activity',
showTimeline: s.tab === 'Timeline',
showHistory: s.tab === 'History',
favClass: s.favorite ? 'on' : '',
favLabel: s.favorite ? 'Favorited' : 'Favorite',
favoriteToggle: this.toggleFavorite,
stage: s.stage,
stageClass: STATUS_CLASS[s.stage] || 'st-gray',
statusLabel,
statusDotCls: isClosed ? 'is-closed' : '',
nextStageLabel: nextStage || 'Rejected',
moveNext: this.moveNext,
stars, ratingText: s.rating ? `${s.rating.toFixed(1)} / 5` : 'Not rated',
appCount: allApplications.length,
applications,
hasMoreApps: allApplications.length > 3,
appsToggleLabel: s.appsExpanded ? 'Show less' : 'View all',
toggleApps: this.toggleApps,
notes: s.notes, noteDraft: s.noteDraft,
onNoteDraftChange: (e) => this.setState({ noteDraft: e.target.value }),
addNote: this.addNote,
noticePeriod: s.noticePeriod, hasNoticePeriod: !!s.noticePeriod, noNoticePeriod: !s.noticePeriod,
expectedSalary: s.expectedSalary, hasExpectedSalary: !!s.expectedSalary, noExpectedSalary: !s.expectedSalary,
goNotes: () => this.selectTab('Notes'),
goForms: () => this.selectTab('Forms'),
goTimeline: () => this.selectTab('Timeline'),
};
}
}
</script>
</body>
</html>

View File

@ -16,14 +16,10 @@ class Position(BaseModel):
date_needed:Optional[date] date_needed:Optional[date]
type:Optional[EmploymentType] type:Optional[EmploymentType]
job_description:Optional[str] job_description:Optional[str]
period_from:Optional[date]=None
period_to:Optional[date]=None
jd_available:Optional[bool]=None
class InternalRecommendate(BaseModel): class InternalRecommendate(BaseModel):
employee_name:Optional[str]=None employee_name:Optional[str]=None
employee_department:Optional[str]=None employee_department:Optional[str]=None
entity:Optional[str]=None
class ReplacementFor(BaseModel): class ReplacementFor(BaseModel):
to_replace:Optional[str] to_replace:Optional[str]

View File

@ -34,13 +34,9 @@ class Requisition(SQLModel, table=True):
), ),
) )
job_description: Optional[str] = None job_description: Optional[str] = None
period_from: Optional[Date] = None
period_to: Optional[Date] = None
jd_available: Optional[bool] = None
employee_name: Optional[str] = None employee_name: Optional[str] = None
employee_department: Optional[str] = None employee_department: Optional[str] = None
entity: Optional[str] = None
to_replace: Optional[str] = None to_replace: Optional[str] = None
grade: Optional[str] = None grade: Optional[str] = None
@ -143,12 +139,8 @@ class Requisition(SQLModel, table=True):
date_needed=position.get("date_needed") if position.get("date_needed") else None, date_needed=position.get("date_needed") if position.get("date_needed") else None,
employment_type=EmploymentType(position.get("type")) if position.get("type") else None, employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
job_description=position.get("job_description") if position.get("job_description") else None, job_description=position.get("job_description") if position.get("job_description") else None,
period_from=position.get("period_from") if position.get("period_from") else None,
period_to=position.get("period_to") if position.get("period_to") else None,
jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
employee_name=referral.get("employee_name") if referral.get("employee_name") else None, employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
employee_department=referral.get("employee_department") if referral.get("employee_department") else None, employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
entity=referral.get("entity") if referral.get("entity") else None,
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None, to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
grade=replacement.get("grade") if replacement.get("grade") else None, grade=replacement.get("grade") if replacement.get("grade") else None,
recruitment_title=replacement.get("title") if replacement.get("title") else None, recruitment_title=replacement.get("title") if replacement.get("title") else None,
@ -191,12 +183,6 @@ class Requisition(SQLModel, table=True):
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
if "job_description" in position: if "job_description" in position:
row.job_description = position.get("job_description") if position.get("job_description") else None row.job_description = position.get("job_description") if position.get("job_description") else None
if "period_from" in position:
row.period_from = position.get("period_from") if position.get("period_from") else None
if "period_to" in position:
row.period_to = position.get("period_to") if position.get("period_to") else None
if "jd_available" in position:
row.jd_available = position.get("jd_available") if position.get("jd_available") is not None else None
if "replacement_for" in fields: if "replacement_for" in fields:
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {} replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
if "to_replace" in replacement: if "to_replace" in replacement:
@ -219,8 +205,6 @@ class Requisition(SQLModel, table=True):
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
if "employee_department" in referral: if "employee_department" in referral:
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
if "entity" in referral:
row.entity = referral.get("entity") if referral.get("entity") else None
if "initiated_by" in fields: if "initiated_by" in fields:
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
if "initiated_date" in fields: if "initiated_date" in fields:

View File

@ -80,8 +80,7 @@ FORM_DEFINITIONS = {
"criteria": [ "criteria": [
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"}, {"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
{"key": "relevant_experience", "label": "Depth of Relevant Experience"}, {"key": "relevant_experience", "label": "Depth of Relevant Experience"},
{"key": "problem_solving", "label": "Problem Solving"}, {"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
{"key": "analytical_reasoning", "label": "Analytical Reasoning"},
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"}, {"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"}, {"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
], ],
@ -110,41 +109,27 @@ FORM_DEFINITIONS = {
), ),
"has_recommendation": True, "has_recommendation": True,
}, },
# form_type/section/field keys below stay "cultural_fit"/"cultural"/"cultural_note" —
# renamed labels only. Titles and criterion labels are denormalized into every
# saved row at write time (see module docstring), so historical rows keep the
# "Cultural Fit" wording they were saved under while new rows pick up the fuller
# revision 2 "HR Evaluation" section below; the key stays stable so old rows keep
# validating and combined_summary()'s "cultural" lookup keeps matching both.
"cultural_fit": { "cultural_fit": {
"title": "HR Evaluation", "title": "Cultural Fit",
"source": "Annexure E - Interview Evaluation Form", "source": "Annexure E - Interview Evaluation Form",
"scale_note": RATING_SCALE_NOTE, "scale_note": RATING_SCALE_NOTE,
"sections": [ "sections": [
{ {
"key": "cultural", "key": "cultural",
"title": "HR EVALUATION", "title": "CULTURAL FIT",
"average_label": "HR EVALUATION SECTION", "average_label": "CULTURAL FIT SECTION",
"criteria": [ "criteria": [
{"key": "basic_jd_requirement", "label": "Basic JD requirement"}, {"key": "company_values", "label": "Alignment with Company Values"},
{"key": "company_values", "label": "Alignment with Company Culture"},
{"key": "professionalism", "label": "Professionalism & Integrity"}, {"key": "professionalism", "label": "Professionalism & Integrity"},
{"key": "collaboration", "label": "Collaboration & Team Orientation"}, {"key": "collaboration", "label": "Collaboration & Team Orientation"},
{"key": "adaptability", "label": "Adaptability"}, {"key": "adaptability", "label": "Adaptability to Change"},
{"key": "agility", "label": "Agility"}, {"key": "work_ethic", "label": "Work Ethic & Reliability"},
{"key": "work_ethic", "label": "Work Ethics"},
{"key": "communication_articulation", "label": "Communication & Articulation"},
{"key": "problem_solving_orientation", "label": "Problem Solving & Solution Orientation"},
{"key": "critical_thinking", "label": "Critical Thinking & Analytical Capability"},
{"key": "initiative", "label": "Initiative & Proactiveness"},
{"key": "decision_making", "label": "Decision Making"},
{"key": "leadership", "label": "Leadership"},
], ],
}, },
], ],
"fields": ( "fields": (
_EVALUATION_HEADER_FIELDS _EVALUATION_HEADER_FIELDS
+ [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}] + [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
+ _EVALUATION_FOOTER_FIELDS + _EVALUATION_FOOTER_FIELDS
), ),
"has_recommendation": True, "has_recommendation": True,
@ -189,7 +174,6 @@ FORM_DEFINITIONS = {
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"}, {"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"}, {"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"},
{"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"}, {"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"},
{"key": "entity", "label": "Entity", "kind": "text"},
{"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"}, {"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"},
{"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"}, {"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"},
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"}, {"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
@ -370,10 +354,7 @@ def combined_summary(rows):
`rows` are candidate_forms records (attribute access: form_type, created_at, `rows` are candidate_forms records (attribute access: form_type, created_at,
sections). The latest interview_analysis row supplies the technical and sections). The latest interview_analysis row supplies the technical and
behavioral averages, the latest cultural_fit row the "cultural" section behavioral averages, the latest cultural_fit row the cultural average.
average cultural_fit's own section carries the fuller HR Evaluation
criteria as of revision 2, but the key stays "cultural" so this lookup
(and the `cultural_avg` key below) don't need to change with it.
The combined overall (mean of the three section averages, 2 dp, already The combined overall (mean of the three section averages, 2 dp, already
ranged onto 0100) appears only once all three exist. Returns None when ranged onto 0100) appears only once all three exist. Returns None when
neither evaluation exists. Legacy 14 section averages are converted neither evaluation exists. Legacy 14 section averages are converted

View File

@ -85,9 +85,6 @@ def serialize_requisition(row) -> dict:
"date_needed": _date(row.date_needed), "date_needed": _date(row.date_needed),
"type": _enum(row.employment_type), "type": _enum(row.employment_type),
"job_description": row.job_description, "job_description": row.job_description,
"period_from": _date(row.period_from),
"period_to": _date(row.period_to),
"jd_available": row.jd_available,
}, },
"replacement_for": { "replacement_for": {
"to_replace": row.to_replace, "to_replace": row.to_replace,
@ -101,7 +98,6 @@ def serialize_requisition(row) -> dict:
"refferal_by": { "refferal_by": {
"employee_name": row.employee_name, "employee_name": row.employee_name,
"employee_department": row.employee_department, "employee_department": row.employee_department,
"entity": row.entity,
}, },
"initiated_by": row.initiated_by, "initiated_by": row.initiated_by,
"initiated_date": _date(row.initiated_date), "initiated_date": _date(row.initiated_date),

View File

@ -1,15 +0,0 @@
-- 037_requisition_period_jd_entity.sql
-- Annexure A revision 2 added three fields the original 020 table never had:
-- "If not permanent, specify the period From/To" on the position block, the
-- mandatory "JD Available Yes/No" flag (distinct from the free-text
-- job_description), and "Entity" alongside Employee Name/Department in the
-- internal-recommendation block. Matches Requisition in
-- backend/candidate_forms/models.py. Applied at startup by
-- alembic_setup.run_manual_sql() — needed because prod boots with
-- DB_AUTOGENERATE=false and never autogenerates new columns.
ALTER TABLE app.requisitions
ADD COLUMN IF NOT EXISTS period_from date,
ADD COLUMN IF NOT EXISTS period_to date,
ADD COLUMN IF NOT EXISTS jd_available boolean,
ADD COLUMN IF NOT EXISTS entity varchar;

View File

@ -52,7 +52,7 @@ class TestNormalizeSections:
normalized, overall = normalize_sections("interview_analysis", []) normalized, overall = normalize_sections("interview_analysis", [])
assert [s["key"] for s in normalized] == ["technical", "behavioral"] assert [s["key"] for s in normalized] == ["technical", "behavioral"]
technical = normalized[0] technical = normalized[0]
assert len(technical["criteria"]) == 6 assert len(technical["criteria"]) == 5
assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise" assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise"
assert technical["average"] is None assert technical["average"] is None
assert overall is None assert overall is None
@ -149,8 +149,8 @@ class TestDefinitions:
def test_paper_parity_criterion_counts(self): def test_paper_parity_criterion_counts(self):
ia = FORM_DEFINITIONS["interview_analysis"] ia = FORM_DEFINITIONS["interview_analysis"]
cf = FORM_DEFINITIONS["cultural_fit"] cf = FORM_DEFINITIONS["cultural_fit"]
assert [len(s["criteria"]) for s in ia["sections"]] == [6, 5] assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5]
assert [len(s["criteria"]) for s in cf["sections"]] == [13] assert [len(s["criteria"]) for s in cf["sections"]] == [5]
def test_stage_gate_vocabulary(self): def test_stage_gate_vocabulary(self):
assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"} assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"}

View File

@ -8,6 +8,7 @@ from users.views import User
from users.permissions import CurrentUser, PermissionTag, require_permission from users.permissions import CurrentUser, PermissionTag, require_permission
from users.serializers import serialize_token from users.serializers import serialize_token
from users.plugins import create_access_token,create_refresh_token from users.plugins import create_access_token,create_refresh_token
from role.models import Roles
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@ -60,7 +61,13 @@ async def login(payload: UserLogin,session: AsyncSession = Depends(get_session))
try: try:
service=User(session=session) service=User(session=session)
user=await service.authenticate_user(payload.email or payload.username,payload.password) user=await service.authenticate_user(payload.email or payload.username,payload.password)
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) permissions=await Roles.resolve_tags(session,user.role)
tokens=serialize_token(
create_access_token(user),
create_refresh_token(user),
user,
permissions=permissions,
)
return JSONResponse(content={**tokens,"status_code":200}) return JSONResponse(content={**tokens,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -73,7 +80,13 @@ async def signup(payload: UserSignup,session: AsyncSession = Depends(get_session
try: try:
service=User(session=session) service=User(session=session)
user=await service.signup_user(payload.model_dump()) user=await service.signup_user(payload.model_dump())
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) permissions=await Roles.resolve_tags(session,user.role)
tokens=serialize_token(
create_access_token(user),
create_refresh_token(user),
user,
permissions=permissions,
)
return JSONResponse(content={**tokens,"status_code":200}) return JSONResponse(content={**tokens,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -86,7 +99,13 @@ async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_sess
try: try:
service=User(session=session) service=User(session=session)
user=await service.refresh_access_token(payload.refresh_token) user=await service.refresh_access_token(payload.refresh_token)
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) permissions=await Roles.resolve_tags(session,user.role)
tokens=serialize_token(
create_access_token(user),
create_refresh_token(user),
user,
permissions=permissions,
)
return JSONResponse(content={**tokens,"status_code":200}) return JSONResponse(content={**tokens,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -33,12 +33,24 @@ def serialize_user(
return data return data
def serialize_token(access_token: str, refresh_token: str, user: Users) -> dict: def serialize_token(
"""Login/refresh payload. OAuth2 fields live at the root so Swagger's Authorize can read them.""" access_token: str,
refresh_token: str,
user: Users,
*,
permissions: tuple[str, ...] | list[str] | None = None,
) -> dict:
"""Login/refresh payload. OAuth2 fields live at the root so Swagger's Authorize can read them.
Always include resolved permissions so the SPA can paint the nav before
GET /users/me returns (and so a stale localStorage role cannot linger).
"""
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS, "expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
"data": serialize_user(user), "data": serialize_user(
user, with_permissions=True, permissions=permissions or ()
),
} }

View File

@ -1,42 +0,0 @@
/**
* Candidate browse queue unique ids, neighbors, profile paths.
*
* node candidate-browse.test.mjs
*/
import assert from 'node:assert/strict'
import { uniqueBrowseEntries, neighborsOf, candidatePath } from './src/lib/candidateBrowse.js'
const rows = [
{ userId: 'a', name: 'Ada', stage: 'Interview', jobTitle: 'Backend' },
{ user_id: 'a', name: 'Ada duplicate application' },
{ userId: '', name: 'Skipped' },
{ userId: 'b', email: 'b@example.com' },
{ userId: 'c', name: 'Chris', job_title: 'Design' },
]
const entries = uniqueBrowseEntries(rows)
assert.deepEqual(entries.map((row) => row.userId), ['a', 'b', 'c'])
assert.equal(entries[0].name, 'Ada')
assert.equal(entries[0].stage, 'Interview')
assert.equal(entries[1].name, 'b@example.com')
assert.equal(entries[2].jobTitle, 'Design')
const mid = neighborsOf(entries, 'b')
assert.equal(mid.index, 1)
assert.equal(mid.total, 3)
assert.equal(mid.prev.userId, 'a')
assert.equal(mid.next.userId, 'c')
const first = neighborsOf(entries, 'a')
assert.equal(first.prev, null)
assert.equal(first.next.userId, 'b')
const missing = neighborsOf(entries, 'z')
assert.equal(missing.index, -1)
assert.equal(missing.prev, null)
assert.equal(missing.next, null)
assert.equal(candidatePath('user/1'), '/candidate/user%2F1')
assert.equal(candidatePath('abc', 'Resume'), '/candidate/abc?tab=Resume')
console.log('All candidate browse checks passed')

View File

@ -57,13 +57,7 @@ page.on('request', async (request) => {
let data = [] let data = []
if (request.method() === 'OPTIONS') return request.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' } }) if (request.method() === 'OPTIONS') return request.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' } })
if (path === '/users/me') data = activeUser if (path === '/users/me') data = activeUser
if (path === '/candidate/fetch') { if (path === '/candidate/fetch') data = candidate
const userId = new URL(request.url()).searchParams.get('user_id')
if (!userId) data = []
else if (userId === 'test-candidate-b') data = { ...candidate, user_id: 'test-candidate-b', name: 'Omar Ali' }
else if (userId === 'test-candidate-c') data = { ...candidate, user_id: 'test-candidate-c', name: 'Hina Raza' }
else data = candidate
}
if (path === '/forms/definitions') data = {} if (path === '/forms/definitions') data = {}
if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` } if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` }
if (path === '/fixture-resume.pdf') return request.respond({ status: 200, contentType: 'application/pdf', body: '%PDF-1.4\n%%EOF' }) if (path === '/fixture-resume.pdf') return request.respond({ status: 200, contentType: 'application/pdf', body: '%PDF-1.4\n%%EOF' })
@ -78,13 +72,6 @@ page.on('request', async (request) => {
}) })
await page.evaluateOnNewDocument((account) => { await page.evaluateOnNewDocument((account) => {
localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'fixture-token', refresh_token: 'fixture-refresh', expires_at: Date.now() + 3600000, data: account })) localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'fixture-token', refresh_token: 'fixture-refresh', expires_at: Date.now() + 3600000, data: account }))
sessionStorage.setItem('tf-candidate-browse', JSON.stringify({
entries: [
{ userId: 'test-candidate', name: 'Sarah Khan', stage: 'Shortlist', jobTitle: 'Marketing Manager' },
{ userId: 'test-candidate-b', name: 'Omar Ali', stage: 'Interview', jobTitle: 'Marketing Manager' },
{ userId: 'test-candidate-c', name: 'Hina Raza', stage: 'Screening', jobTitle: 'Content Lead' },
],
}))
}, user) }, user)
async function clickText(selector, text) { async function clickText(selector, text) {
const clicked = await page.evaluate((selector, text) => { const clicked = await page.evaluate((selector, text) => {
@ -102,8 +89,7 @@ try {
await load() await load()
assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan') assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan')
assert.equal(await page.$$eval('.cw-applications tbody tr', (rows) => rows.length), 3) assert.equal(await page.$$eval('.cw-applications tbody tr', (rows) => rows.length), 3)
assert.ok(await page.$('.sidebar'), 'Candidate page keeps the app sidebar') assert.equal(await page.$('.sidebar'), null, 'Detail page must use full width')
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '1 of 3')
assert.equal(writes.length, 0, 'Opening a candidate is read-only') assert.equal(writes.length, 0, 'Opening a candidate is read-only')
for (const width of [1536, 1280, 1024, 768, 390, 320]) { for (const width of [1536, 1280, 1024, 768, 390, 320]) {
await page.setViewport({ width, height: 1100 }) await page.setViewport({ width, height: 1100 })
@ -116,13 +102,6 @@ try {
} }
console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)') console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)')
await page.setViewport({ width: 1536, height: 1100 }) await page.setViewport({ width: 1536, height: 1100 })
await page.click('[aria-label="Next candidate"]')
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Omar Ali')
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '2 of 3')
assert.match(page.url(), /\/candidate\/test-candidate-b/)
await page.click('[aria-label="Previous candidate"]')
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Sarah Khan')
console.log('ok Previous / next walks the candidate list')
await clickText('.cand-page-actions button', 'Favorite') await clickText('.cand-page-actions button', 'Favorite')
await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true') await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
await page.click('.cw-rating [role=radio]:nth-child(5)') await page.click('.cw-rating [role=radio]:nth-child(5)')

View File

@ -16,9 +16,8 @@
"test:candidates": "node candidates-table.test.mjs", "test:candidates": "node candidates-table.test.mjs",
"test:profile": "node candidate-profile.test.mjs", "test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs", "test:cvbank": "node cvbank.test.mjs",
"test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.test.mjs", "test:mobile": "node mobile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node candidate-browse.test.mjs" "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",

View File

@ -1,5 +1,5 @@
import { lazy } from 'react' import { lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import AuthProvider from './auth/AuthProvider' import AuthProvider from './auth/AuthProvider'
import RequireAuth from './auth/RequireAuth' import RequireAuth from './auth/RequireAuth'
@ -47,14 +47,6 @@ const SCREENS = {
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). // Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
const CandidatePage = lazy(() => import('./screens/CandidatePage')) const CandidatePage = lazy(() => import('./screens/CandidatePage'))
function LegacyCandidateRedirect() {
const { userId } = useParams()
const [params] = useSearchParams()
const tab = params.get('tab')
const to = `/candidate/${encodeURIComponent(userId)}`
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
@ -103,14 +95,6 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/candidates/:userId"
element={
<RequireAuth permission="candidates.view">
<LegacyCandidateRedirect />
</RequireAuth>
}
/>
</Route> </Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -5,8 +5,8 @@ import { request } from '../lib/apiClient'
The digitized hiring forms: Annexure A (Employee Requisition), and the two The digitized hiring forms: Annexure A (Employee Requisition), and the two
halves of Annexure E Interview Analysis (technical + behavioral) and halves of Annexure E Interview Analysis (technical + behavioral) and
HR Evaluation (form_type stays "cultural_fit"). Dual-key like assessments: Cultural Fit. Dual-key like assessments: exactly one of inbox_id /
exactly one of inbox_id / manual_upload_candidate_id. manual_upload_candidate_id.
Permissioned with the interviews module tags (interviews.view to read, Permissioned with the interviews module tags (interviews.view to read,
interviews.create to fill, interviews.edit to amend). Creating is interviews.create to fill, interviews.edit to amend). Creating is

View File

@ -12,17 +12,9 @@ export function openUrl(key, { expiresIn } = {}) {
}) })
} }
function asList(value) {
return Array.isArray(value) ? value : []
}
/** First comma-separated stored path — inbox_messages.file_path can list several. */ /** First comma-separated stored path — inbox_messages.file_path can list several. */
export function firstKey(filePath) { export function firstKey(filePath) {
if (Array.isArray(filePath)) return firstKey(filePath[0]) return (filePath || '').split(',')[0].trim() || null
if (filePath && typeof filePath === 'object') {
return firstKey(filePath.url || filePath.path || filePath.key)
}
return String(filePath || '').split(',')[0].trim() || null
} }
/** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */ /** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */
@ -45,17 +37,9 @@ export function canOpen(filePath) {
/** First usable S3/http ref on a candidate or inbox payload. */ /** First usable S3/http ref on a candidate or inbox payload. */
export function resumeKeyFrom(item) { export function resumeKeyFrom(item) {
if (!item) return null if (!item) return null
if (typeof item.files === 'string') { const fromFiles = (item.files || []).map((f) => f.url).find(Boolean)
const key = firstKey(item.files)
if (key) return key
}
const fromFiles = asList(item.files).map((f) => (typeof f === 'string' ? f : f?.url || f?.path)).find(Boolean)
if (fromFiles) return firstKey(fromFiles) if (fromFiles) return firstKey(fromFiles)
if (typeof item.documents === 'string') { const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean)
const key = firstKey(item.documents)
if (key) return key
}
const fromDocs = asList(item.documents).map((d) => (typeof d === 'string' ? d : d?.path || d?.url)).find(Boolean)
if (fromDocs) return firstKey(fromDocs) if (fromDocs) return firstKey(fromDocs)
return firstKey(item.file_path || item.filePath) return firstKey(item.file_path || item.filePath)
} }

View File

@ -39,17 +39,17 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen]) }, [location.pathname, setNavOpen])
return ( return (
<div id="app" className={candidateView ? 'candidate-workspace' : undefined}> <div id="app" className={candidateView ? 'candidate-workspace' : undefined} data-theme={candidateView ? 'dark' : undefined}>
<a className="skip-link" href="#main-content">Skip to content</a> <a className="skip-link" href="#main-content">Skip to content</a>
<Sidebar {(!candidateView || navOpen) && <Sidebar
collapsed={collapsed} collapsed={collapsed}
mobileOpen={navOpen} mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed} onToggleCollapse={toggleCollapsed}
badges={badges} badges={badges}
/> />}
<div className="main-wrap"> <div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} /> <Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} candidateView={candidateView} />
<main className="content" id="main-content" ref={contentRef}> <main className="content" id="main-content" ref={contentRef}>
{/* Keyed by pathname: navigating away from a crashed screen resets it. */} {/* Keyed by pathname: navigating away from a crashed screen resets it. */}
<ErrorBoundary key={location.pathname}> <ErrorBoundary key={location.pathname}>
@ -60,14 +60,14 @@ export default function AppLayout() {
</main> </main>
</div> </div>
<button {!candidateView && <button
className="ai-fab" className="ai-fab"
onClick={() => setDockOpen(true)} onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant" title="AI Recruiter Assistant"
aria-label="Open AI Assistant" aria-label="Open AI Assistant"
> >
<Icon name="sparkles" /> <Icon name="sparkles" />
</button> </button>}
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} /> <AiDock open={dockOpen} onClose={() => setDockOpen(false)} />

View File

@ -7,7 +7,6 @@ import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon } from '../ui/primitives' import { Avatar, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { openCandidateProfile } from '../lib/candidateBrowse'
import * as searchApi from '../api/search' import * as searchApi from '../api/search'
export default function GlobalSearch({ inputRef }) { export default function GlobalSearch({ inputRef }) {
@ -80,7 +79,7 @@ export default function GlobalSearch({ inputRef }) {
{candidates.length > 0 && <div className="search-group-label">Candidates</div>} {candidates.length > 0 && <div className="search-group-label">Candidates</div>}
{candidates.map((c) => ( {candidates.map((c) => (
<div key={c.id} className="search-item" onClick={() => openCandidateProfile(navigate, c.id, candidates.map((row) => ({ userId: row.id, name: row.name })))}> <div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
<Avatar name={c.name} /> <Avatar name={c.name} />
<div> <div>
<div className="si-title">{c.name}</div> <div className="si-title">{c.name}</div>

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Dropdown, { DropdownGroup } from '../ui/Dropdown' import Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch' import GlobalSearch from './GlobalSearch'
import { BrandGlyph } from '../components/BrandMark'
import { Icon } from '../ui/primitives' import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider' import { useTheme } from '../theme/ThemeProvider'
@ -28,7 +29,7 @@ async function fetchNotifications() {
} }
} }
export default function Topbar({ onOpenNav, searchRef }) { export default function Topbar({ onOpenNav, searchRef, candidateView = false }) {
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const { user, signOut } = useAuth() const { user, signOut } = useAuth()
const { toast } = useToast() const { toast } = useToast()
@ -68,6 +69,10 @@ export default function Topbar({ onOpenNav, searchRef }) {
return ( return (
<header className="topbar"> <header className="topbar">
{candidateView && <Link to="/candidates" className="candidate-brand" aria-label="Utopia Brands — Candidates">
<BrandGlyph />
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
</Link>}
<button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu"> <button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
@ -75,7 +80,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
<GlobalSearch inputRef={searchRef} /> <GlobalSearch inputRef={searchRef} />
<div className="topbar-actions"> <div className="topbar-actions">
<button {!candidateView && <button
className="icon-btn" className="icon-btn"
onClick={toggleTheme} onClick={toggleTheme}
aria-pressed={theme === 'dark'} aria-pressed={theme === 'dark'}
@ -84,7 +89,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
> >
<span className="icon-sun"><Icon name="sun" /></span> <span className="icon-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span> <span className="icon-moon"><Icon name="moon" /></span>
</button> </button>}
<DropdownGroup> <DropdownGroup>
<Dropdown <Dropdown

View File

@ -45,6 +45,23 @@ export default function AuthProvider({ children }) {
if (me.data) mergeUser(me.data) if (me.data) mergeUser(me.data)
}, [me.data]) }, [me.data])
// A failed /users/me means the stored session can no longer be trusted: the
// token is dead, the account was deactivated, or the case this exists for
// the record predates a role change and its cached identity and permissions
// are simply wrong. Purge it instead of leaving it on disk, because the
// optimistic paint below treats cached permissions as good enough to render
// the shell, so a stale record keeps showing the old role and the old nav on
// every load until someone clears storage by hand.
//
// Genuine expiry is already handled upstream (apiClient refreshes, and calls
// the session-expired handler when that fails); this catches everything else.
useEffect(() => {
if (!me.isError) return
clearSession()
qc.clear()
navigate('/auth/login?expired=1', { replace: true })
}, [me.isError, navigate, qc])
const signIn = useCallback( const signIn = useCallback(
async (email, password) => { async (email, password) => {
const res = await authApi.login(email, password) const res = await authApi.login(email, password)
@ -64,8 +81,11 @@ export default function AuthProvider({ children }) {
navigate('/auth/login', { replace: true }) navigate('/auth/login', { replace: true })
}, [navigate, qc]) }, [navigate, qc])
const user = me.data ?? session?.data ?? null // Once /users/me has failed the cached snapshot is not a fallback, it is the
const permissions = me.data?.permissions ?? session?.data?.permissions ?? null // thing that was wrong serving it would paint a stale name, role and nav.
const cachedUser = me.isError ? null : session?.data
const user = me.data ?? cachedUser ?? null
const permissions = me.data?.permissions ?? cachedUser?.permissions ?? null
const status = !session?.access_token const status = !session?.access_token
? 'anonymous' ? 'anonymous'

View File

@ -25,11 +25,6 @@ export default class ErrorBoundary extends Component {
<EmptyState icon="alert" title="Something went wrong"> <EmptyState icon="alert" title="Something went wrong">
This screen hit an unexpected error. The rest of the app is fine This screen hit an unexpected error. The rest of the app is fine
try again, or head back to the dashboard. try again, or head back to the dashboard.
{this.state.error?.message ? (
<div className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
{this.state.error.message}
</div>
) : null}
</EmptyState> </EmptyState>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<button className="btn btn-secondary" onClick={() => this.setState({ error: null })}> <button className="btn btn-secondary" onClick={() => this.setState({ error: null })}>

View File

@ -1,64 +0,0 @@
/* Session-scoped candidate queue so Previous / Next on the profile walks
the same list the recruiter was looking at (Candidates table, Pipeline
board, CV Bank, search), not an arbitrary server page. */
export const BROWSE_KEY = 'tf-candidate-browse'
export function uniqueBrowseEntries(rows = []) {
const entries = []
const seen = new Set()
for (const row of rows) {
if (!row) continue
const userId = String(row.userId ?? row.user_id ?? '').trim()
if (!userId || seen.has(userId)) continue
seen.add(userId)
entries.push({
userId,
name: String(row.name || row.email || 'Candidate'),
stage: row.stage || null,
jobTitle: row.jobTitle || row.job_title || null,
})
}
return entries
}
export function neighborsOf(entries, userId) {
const id = String(userId ?? '')
const index = entries.findIndex((entry) => entry.userId === id)
return {
index,
total: entries.length,
prev: index > 0 ? entries[index - 1] : null,
next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : null,
current: index >= 0 ? entries[index] : null,
entries,
}
}
export function candidatePath(userId, tab) {
const path = `/candidate/${encodeURIComponent(userId)}`
return tab ? `${path}?tab=${encodeURIComponent(tab)}` : path
}
export function rememberCandidateBrowse(rows) {
const entries = uniqueBrowseEntries(rows)
try {
sessionStorage.setItem(BROWSE_KEY, JSON.stringify({ entries }))
} catch { /* private mode / quota — browsing still works for this click */ }
return entries
}
export function readCandidateBrowse() {
try {
const parsed = JSON.parse(sessionStorage.getItem(BROWSE_KEY) || 'null')
return uniqueBrowseEntries(parsed?.entries || [])
} catch {
return []
}
}
export function openCandidateProfile(navigate, userId, rows, { replace = false, tab } = {}) {
if (rows) rememberCandidateBrowse(rows)
if (!userId) return
navigate(candidatePath(userId, tab), { replace })
}

View File

@ -107,7 +107,6 @@ export const qk = {
applications: (email) => ['candidates', 'applications', email], applications: (email) => ['candidates', 'applications', email],
matching: (p = {}) => ['candidates', 'matching', p], matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id], matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
browse: (p = {}) => ['candidates', 'browse', p],
}, },
// Board rows come from the same endpoint as qk.candidates.list but are cached // Board rows come from the same endpoint as qk.candidates.list but are cached
// MAPPED (kanban cards, not the raw envelope), so they need their own key — // MAPPED (kanban cards, not the raw envelope), so they need their own key —

View File

@ -79,12 +79,20 @@ export function isExpiring(skew = CLOCK_SKEW_MS) {
* mid-session. * mid-session.
*/ */
export function setSession(res) { export function setSession(res) {
const incoming = res.data ?? {}
const prev = cached?.data ?? {}
cached = { cached = {
access_token: res.access_token, access_token: res.access_token,
refresh_token: res.refresh_token, refresh_token: res.refresh_token,
expires_in: res.expires_in, expires_in: res.expires_in,
expires_at: Date.now() + (res.expires_in ?? 1800) * 1000, expires_at: Date.now() + (res.expires_in ?? 1800) * 1000,
data: { ...(cached?.data ?? {}), ...(res.data ?? {}) }, // Keep prior permissions when a token response omits them (legacy refresh);
// when login/me includes permissions, those win.
data: {
...prev,
...incoming,
permissions: incoming.permissions ?? prev.permissions,
},
} }
persist() persist()
emit() emit()

View File

@ -1,31 +0,0 @@
import { Icon } from '../ui/primitives'
export function CandidateBrowseNav({ browse, onBrowse }) {
if (!browse || browse.index < 0 || browse.total < 1) return null
const { index, total, prev, next } = browse
return (
<div className="cw-browse-nav">
<button
type="button"
className="btn btn-secondary btn-sm cw-browse-arrow"
disabled={!prev}
aria-label="Previous candidate"
title={prev ? `Previous: ${prev.name}` : 'No previous candidate'}
onClick={() => prev && onBrowse(prev.userId)}
>
<Icon name="chevron-left" />
</button>
<span className="cw-browse-counter">{index + 1} of {total}</span>
<button
type="button"
className="btn btn-secondary btn-sm cw-browse-arrow"
disabled={!next}
aria-label="Next candidate"
title={next ? `Next: ${next.name}` : 'No next candidate'}
onClick={() => next && onBrowse(next.userId)}
>
<Icon name="chevron-right" />
</button>
</div>
)
}

View File

@ -1,9 +1,7 @@
/* The Forms tab of the candidate profile modal Interview Analysis + HR /* The Forms tab of the candidate profile modal Interview Analysis + Cultural
Evaluation (the two halves of Annexure E; form_type stays "cultural_fit" Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the
only its title/criteria grew to the fuller HR Evaluation section in offers table). Employee Requisition (Annexure A) lives on the Requisitions
revision 2), and the Offer (Annexure J fields on the offers table). screen, not on a candidate.
Employee Requisition (Annexure A) lives on the Requisitions screen, not on
a candidate.
Field and criterion labels are rendered from GET /forms/definitions the Field and criterion labels are rendered from GET /forms/definitions the
backend is the single authority for the paper forms' exact wording. The backend is the single authority for the paper forms' exact wording. The
@ -163,7 +161,7 @@ export default function CandidateFormsTab({ userId, live }) {
} }
const segTabs = [ const segTabs = [
{ key: 'interview_analysis', label: 'Interview Analysis' }, { key: 'interview_analysis', label: 'Interview Analysis' },
{ key: 'cultural_fit', label: 'HR Evaluation' }, { key: 'cultural_fit', label: 'Cultural Fit' },
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []), ...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
] ]
@ -235,7 +233,7 @@ function SummaryStrip({ summary, evalCount }) {
<div className="hf-summary" style={{ marginBottom: 6 }}> <div className="hf-summary" style={{ marginBottom: 6 }}>
<ScoreTile label="Technical" value={summary.technical_avg} /> <ScoreTile label="Technical" value={summary.technical_avg} />
<ScoreTile label="Behavioral" value={summary.behavioral_avg} /> <ScoreTile label="Behavioral" value={summary.behavioral_avg} />
<ScoreTile label="HR Evaluation" value={summary.cultural_avg} /> <ScoreTile label="Cultural Fit" value={summary.cultural_avg} />
<ScoreTile <ScoreTile
label="Combined Overall" label="Combined Overall"
value={summary.combined_overall} value={summary.combined_overall}
@ -337,7 +335,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Interview Analysis / HR Evaluation data-driven off the definition's Interview Analysis / Cultural Fit data-driven off the definition's
sections; both types share this component. */ sections; both types share this component. */
function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) { function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) {

View File

@ -4,87 +4,21 @@
need a real page with a real URL (shareable, refresh-safe). This is a thin need a real page with a real URL (shareable, refresh-safe). This is a thin
shell over CandidateProfile in `variant="page"` mode: the identity shell shell over CandidateProfile in `variant="page"` mode: the identity shell
carries only the userId and the live detail query fills everything else. carries only the userId and the live detail query fills everything else.
Opened from Candidates, Talent Pool and the Pipeline board. Opened from Candidates, Talent Pool and the Pipeline board. */
Previous / Next walk the session queue written when the recruiter opened import { useNavigate, useParams } from 'react-router-dom'
this profile from a list. A direct URL falls back to the first page of
candidates they can already see, so the arrows still do something. */
import { useEffect } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { candidatePath, neighborsOf, readCandidateBrowse, uniqueBrowseEntries } from '../lib/candidateBrowse'
import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
function useCandidateBrowse(userId) {
const { user } = useAuth()
const manager = isHiringManager(user)
const stored = readCandidateBrowse()
const fallback = useQuery({
queryKey: qk.candidates.browse({ manager }),
queryFn: async () => {
if (manager) {
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
const rows = Array.isArray(res?.data) ? res.data : []
return uniqueBrowseEntries(rows.map((row) => ({
...row,
stage: pipelineApi.STAGE_FROM_STATUS[String(row.application_status || '').toUpperCase()] || null,
})))
}
const res = await candidatesApi.list({ limit: 100, offset: 0 })
const rows = Array.isArray(res?.data) ? res.data.map(candidatesApi.toApplicationListView) : []
return uniqueBrowseEntries(rows)
},
enabled: stored.length === 0 && Boolean(userId),
staleTime: 30_000,
})
const entries = stored.length ? stored : (fallback.data ?? [])
return neighborsOf(entries, userId)
}
export default function CandidatePage() { export default function CandidatePage() {
const { userId } = useParams() const { userId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const browse = useCandidateBrowse(userId)
const tab = searchParams.get('tab') || undefined
function goTo(id) {
if (!id || String(id) === String(userId)) return
navigate(candidatePath(id, tab))
}
useEffect(() => {
function onKey(event) {
if (!event.altKey || event.metaKey || event.ctrlKey) return
const tag = event.target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable) return
if (event.key === 'ArrowLeft' && browse.prev) {
event.preventDefault()
goTo(browse.prev.userId)
}
if (event.key === 'ArrowRight' && browse.next) {
event.preventDefault()
goTo(browse.next.userId)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [browse.prev, browse.next, userId, tab])
return ( return (
<CandidateProfile <CandidateProfile
key={userId} key={userId}
variant="page" variant="page"
candidate={{ id: userId, userId, name: '' }} candidate={{ id: userId, userId, name: '' }}
browse={browse}
onBrowse={goTo}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))} onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
/> />
) )

View File

@ -20,7 +20,6 @@ import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3' import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms' import CandidateFormsTab from './CandidateForms'
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace' import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
import { CandidateBrowseNav } from './CandidateBrowse'
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory' import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format' import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed' import { companies, moneyK, pick } from '../data/seed'
@ -110,27 +109,18 @@ function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
*/ */
export default function CandidateProfile({ export default function CandidateProfile({
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch, candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
variant = 'modal', browse = null, onBrowse, variant = 'modal',
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const { can, user } = useAuth() const { can, user } = useAuth()
const isManager = isHiringManager(user) const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [searchParams, setSearchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [tab, setTab] = useState(() => tabFromSearch( const [tab, setTab] = useState(() => tabFromSearch(
variant === 'page' ? searchParams.get('tab') : null, variant === 'page' ? searchParams.get('tab') : null,
visibleTabs, visibleTabs,
isManager ? 'Forms' : 'Overview', isManager ? 'Forms' : 'Overview',
)) ))
function changeTab(next) {
setTab(next)
if (variant !== 'page') return
setSearchParams((prev) => {
const params = new URLSearchParams(prev)
params.set('tab', next)
return params
}, { replace: true })
}
const tabId = useId() const tabId = useId()
const [dialog, setDialog] = useState(null) const [dialog, setDialog] = useState(null)
const [stageReason, setStageReason] = useState('') const [stageReason, setStageReason] = useState('')
@ -145,7 +135,7 @@ export default function CandidateProfile({
// employer" changed every repaint. Fixed per candidate. // employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), []) const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = (Array.isArray(interviews) ? interviews : []).filter((i) => i.candidateId === c.id) const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
// The application row interviews/activity/feedback attach to. Detail mode // The application row interviews/activity/feedback attach to. Detail mode
// flattens every application the candidate owns; writes land on the first, // flattens every application the candidate owns; writes land on the first,
@ -256,7 +246,7 @@ export default function CandidateProfile({
const counts = live && { const counts = live && {
Interview: live.interviews?.length ?? 0, Interview: live.interviews?.length ?? 0,
Forms: (Array.isArray(formsQuery.data?.data) ? formsQuery.data.data : []).filter((r) => r.form_type !== 'requisition').length, Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length,
Notes: live.notes?.length ?? 0, Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0, Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0, Documents: live.documents?.length ?? 0,
@ -378,7 +368,7 @@ export default function CandidateProfile({
<Tabs <Tabs
idBase={tabId} idBase={tabId}
value={tab} value={tab}
onChange={changeTab} onChange={setTab}
className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'} className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'}
tabs={visibleTabs.map((t) => ({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))} tabs={visibleTabs.map((t) => ({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))}
/> />
@ -386,10 +376,10 @@ export default function CandidateProfile({
<div className={`tab-pane active${variant === 'page' && tab !== 'Overview' ? ' cw-tab-content' : ''}`} role="tabpanel" id={`${tabId}-panel-${visibleTabs.indexOf(tab)}`} aria-labelledby={`${tabId}-tab-${visibleTabs.indexOf(tab)}`}> <div className={`tab-pane active${variant === 'page' && tab !== 'Overview' ? ' cw-tab-content' : ''}`} role="tabpanel" id={`${tabId}-panel-${visibleTabs.indexOf(tab)}`} aria-labelledby={`${tabId}-tab-${visibleTabs.indexOf(tab)}`}>
{tab === 'Overview' && (guard || (variant === 'page' ? <CandidateWorkspaceOverview {tab === 'Overview' && (guard || (variant === 'page' ? <CandidateWorkspaceOverview
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction} candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={setTab} onAction={openAction}
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)} rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
atsScore={atsScore} recommendation={recommendation} atsScore={atsScore} recommendation={recommendation}
atsAction={canRerunAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={rerunAts.isPending} onClick={() => rerunAts.mutate()}><Icon name="sparkles" />{rerunAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null} atsAction={canScoreAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={scoreAts.isPending} onClick={() => scoreAts.mutate()}><Icon name="sparkles" />{scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null}
/> : live ? ( /> : live ? (
<> <>
<PreviousApplications row={live} /> <PreviousApplications row={live} />
@ -445,11 +435,11 @@ export default function CandidateProfile({
</> </>
)} )}
{Array.isArray(live.job_posts) && live.job_posts.length > 0 && ( {live.job_posts?.length > 0 && (
<> <>
<div style={LABEL}>Suggested Roles</div> <div style={LABEL}>Suggested Roles</div>
<div className="k-tags"> <div className="k-tags">
{live.job_posts.filter(Boolean).map((j) => <span className="tag" key={j.id || j.title}>{j.title}</span>)} {live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
</div> </div>
</> </>
)} )}
@ -471,7 +461,7 @@ export default function CandidateProfile({
<Info label="Rating" val={`${c.rating} / 5.0`} /> <Info label="Rating" val={`${c.rating} / 5.0`} />
</div> </div>
<div style={LABEL}>Skills</div> <div style={LABEL}>Skills</div>
<div className="k-tags">{(Array.isArray(c.skills) ? c.skills : []).map((s) => <span className="tag" key={s}>{s}</span>)}</div> <div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</> </>
)))} )))}
@ -662,16 +652,15 @@ export default function CandidateProfile({
return ( return (
<div className="cand-page"> <div className="cand-page">
<div className="cand-page-bar"> <div className="cand-page-bar">
<div className="cand-page-crumb">
<button className="btn btn-secondary btn-sm" onClick={onClose}> <button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back <Icon name="chevron-left" /> Back
</button> </button>
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong> <div className="cand-page-crumb">
</div> Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
<div className="cand-page-actions">
{!isManager && <button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>}
{onBrowse && <CandidateBrowseNav browse={browse} onBrowse={onBrowse} />}
</div> </div>
{!isManager && <div className="cand-page-actions">
<button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>
</div>}
</div> </div>
{body} {body}
{dialog && live && <Modal {dialog && live && <Modal

View File

@ -17,16 +17,7 @@ const display = (value) => value === 0 ? '0' : value || '—'
const experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value const experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value
const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city
const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle
function asList(value) { const profileSkills = (candidate) => [...new Set((candidate.skills?.length ? candidate.skills : candidate.matched_keywords || []).filter((skill) => typeof skill === 'string' && skill.trim()))]
if (Array.isArray(value)) return value.filter((item) => item != null)
if (typeof value === 'string' && value.trim()) return value.split(/[,;]/).map((item) => item.trim()).filter(Boolean)
return []
}
const profileSkills = (candidate) => {
const skills = asList(candidate?.skills)
const keywords = asList(candidate?.matched_keywords)
return [...new Set((skills.length ? skills : keywords).filter((skill) => typeof skill === 'string' && skill.trim()))]
}
function externalUrl(value) { function externalUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null } try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null }
@ -160,14 +151,13 @@ function Applications({ candidate, stage }) {
function DocumentRow({ document, index, candidate, resume = false }) { function DocumentRow({ document, index, candidate, resume = false }) {
const download = useDownload(candidate) const download = useDownload(candidate)
const path = typeof document === 'string' ? document : document?.path const name = document.name || 'Candidate document'
const name = (typeof document === 'string' ? document : document?.name) || 'Candidate document'
const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE' const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE'
return <div className="cw-document"> return <div className="cw-document">
<span className="cw-document-icon"><Icon name="file" /><small>{ext}</small></span> <span className="cw-document-icon"><Icon name="file" /><small>{ext}</small></span>
<div className="cw-document-name"><strong title={name}>{name}</strong><small>{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}</small></div> <div className="cw-document-name"><strong title={name}>{name}</strong><small>{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}</small></div>
<div className="cw-document-actions"> <div className="cw-document-actions">
<OpenResumeButton filePath={path} label={resume ? 'Preview' : `Preview ${name}`} icon="eye" className={resume ? 'btn btn-secondary btn-sm' : 'btn btn-secondary btn-sm cw-icon-label'} /> <OpenResumeButton filePath={document.path} label={resume ? 'Preview' : `Preview ${name}`} icon="eye" className={resume ? 'btn btn-secondary btn-sm' : 'btn btn-secondary btn-sm cw-icon-label'} />
{(candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary btn-sm" disabled={download.isPending} aria-label={`Download ${name}`} title={`Download ${name}`} onClick={() => download.mutate({ index, filename: name })}><Icon name="download" />{resume && <span>{download.isPending ? 'Downloading…' : 'Download'}</span>}</button>} {(candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary btn-sm" disabled={download.isPending} aria-label={`Download ${name}`} title={`Download ${name}`} onClick={() => download.mutate({ index, filename: name })}><Icon name="download" />{resume && <span>{download.isPending ? 'Downloading…' : 'Download'}</span>}</button>}
</div> </div>
</div> </div>
@ -175,9 +165,9 @@ function DocumentRow({ document, index, candidate, resume = false }) {
function RecentActivity({ candidate, onTab }) { function RecentActivity({ candidate, onTab }) {
const events = [ const events = [
...asList(candidate.activity).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })), ...(candidate.activity || []).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })),
...asList(candidate.notes).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })), ...(candidate.notes || []).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })),
...asList(candidate.interviews).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), ...(candidate.interviews || []).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })),
...(candidate.matched_at ? [{ title: 'Screening completed', detail: candidate.match_summary || candidate.match_status, date: candidate.matched_at }] : []), ...(candidate.matched_at ? [{ title: 'Screening completed', detail: candidate.match_summary || candidate.match_status, date: candidate.matched_at }] : []),
...(candidate.applied ? [{ title: 'Application received', detail: candidate.source ? `Applied via ${candidate.source}` : appliedRole(candidate), date: candidate.applied }] : []), ...(candidate.applied ? [{ title: 'Application received', detail: candidate.source ? `Applied via ${candidate.source}` : appliedRole(candidate), date: candidate.applied }] : []),
].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4) ].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4)
@ -193,7 +183,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage
const { can } = useAuth() const { can } = useAuth()
const { toast } = useToast() const { toast } = useToast()
const skills = profileSkills(candidate) const skills = profileSkills(candidate)
const documents = asList(candidate.documents) const documents = candidate.documents || []
const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id) const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id)
const isClosed = ['Rejected', 'Hired'].includes(stage) const isClosed = ['Rejected', 'Hired'].includes(stage)
const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress' const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress'
@ -234,7 +224,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage
<div className="cw-summary">{candidate.match_summary && <p>{candidate.match_summary}</p>}{candidate.match_reasoning && <p>{candidate.match_reasoning}</p>}{score == null && !candidate.match_summary && !candidate.match_reasoning && <p>Compare this candidates resume with the assigned role.</p>}</div> <div className="cw-summary">{candidate.match_summary && <p>{candidate.match_summary}</p>}{candidate.match_reasoning && <p>{candidate.match_reasoning}</p>}{score == null && !candidate.match_summary && !candidate.match_reasoning && <p>Compare this candidates resume with the assigned role.</p>}</div>
</div> </div>
</WorkspaceCard>} </WorkspaceCard>}
{asList(candidate.job_posts).length > 0 && <WorkspaceCard title="Suggested Roles"><div className="cw-skills">{asList(candidate.job_posts).map((job) => <span key={job.id || job.title}>{job.title || job.id}</span>)}</div></WorkspaceCard>} {candidate.job_posts?.length > 0 && <WorkspaceCard title="Suggested Roles"><div className="cw-skills">{candidate.job_posts.map((job) => <span key={job.id}>{job.title}</span>)}</div></WorkspaceCard>}
</div> </div>
<aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity"> <aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity">
<WorkspaceCard title="Quick Actions"><div className="cw-action-grid"> <WorkspaceCard title="Quick Actions"><div className="cw-action-grid">

View File

@ -35,7 +35,6 @@ import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantH
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries' import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
import { openCandidateProfile } from '../lib/candidateBrowse'
const EMPTY_FILTERS = { assignment: '', stage: '', band: '' } const EMPTY_FILTERS = { assignment: '', stage: '', band: '' }
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
@ -162,6 +161,11 @@ function HiringManagerCandidates() {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
if (id) navigate(`/candidate/${id}`, { replace: true })
}, [location.state, navigate])
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: qk.candidates.managerList(), queryKey: qk.candidates.managerList(),
queryFn: async () => { queryFn: async () => {
@ -192,11 +196,6 @@ function HiringManagerCandidates() {
}) })
}, [rowsAll, q, jobId]) }, [rowsAll, q, jobId])
useEffect(() => {
const id = location.state?.openCandidate
if (id) openCandidateProfile(navigate, id, rows.length ? rows : undefined, { replace: true })
}, [location.state, navigate, rows])
const columns = [ const columns = [
{ {
key: 'name', key: 'name',
@ -289,7 +288,7 @@ function HiringManagerCandidates() {
? 'No candidates match these filters.' ? 'No candidates match these filters.'
: 'No candidates are allocated to jobs opened from your requisitions yet.' : 'No candidates are allocated to jobs opened from your requisitions yet.'
} }
onRowClick={(r) => r.user_id && openCandidateProfile(navigate, r.user_id, rows)} onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
/> />
)} )}
</div> </div>
@ -393,10 +392,10 @@ function RecruiterCandidates() {
// Real candidates get the full profile PAGE; the modal stays only as the // Real candidates get the full profile PAGE; the modal stays only as the
// fallback for rows without a user account. // fallback for rows without a user account.
const uid = c.userId const uid = c.userId
if (uid) openCandidateProfile(navigate, uid, candidates) if (uid) navigate(`/candidate/${uid}`)
else setProfileFor(c) else setProfileFor(c)
}, },
[qc, navigate, candidates], [qc, navigate],
) )
// Deep links from Talent Pool, global search, dashboard // Deep links from Talent Pool, global search, dashboard
@ -404,8 +403,8 @@ function RecruiterCandidates() {
const st = location.state const st = location.state
if (!st) return if (!st) return
if (st.openAdd) setAdding(true) if (st.openAdd) setAdding(true)
if (st.openCandidate) openCandidateProfile(navigate, st.openCandidate, candidates.length ? candidates : undefined, { replace: true }) if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
}, [location.state, navigate, candidates]) }, [location.state, navigate])
const rows = useMemo(() => { const rows = useMemo(() => {
const f = filters const f = filters

View File

@ -32,7 +32,6 @@ import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3' import * as s3Api from '../api/s3'
import { openCandidateProfile } from '../lib/candidateBrowse'
import { avatarColor, initials as initialsOf } from '../data/seed' import { avatarColor, initials as initialsOf } from '../data/seed'
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
@ -216,7 +215,7 @@ export default function CvBank() {
async function view(row) { async function view(row) {
if (!row.isStoredCv) { if (!row.isStoredCv) {
if (row.userId) openCandidateProfile(navigate, row.userId, rows) if (row.userId) navigate(`/candidate/${row.userId}`)
else toast('This applicant has no profile to open', 'info') else toast('This applicant has no profile to open', 'info')
return return
} }

View File

@ -27,7 +27,6 @@ import { friendlyAuthError } from '../lib/errors'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline' import * as pipelineApi from '../api/pipeline'
import { ReappliedBadge } from '../components/ReapplicantHistory' import { ReappliedBadge } from '../components/ReapplicantHistory'
import { openCandidateProfile } from '../lib/candidateBrowse'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */ /* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [ export const KANBAN_STAGES = [
@ -299,7 +298,7 @@ export default function Pipeline() {
// The profile page keys off users.id, so an application // The profile page keys off users.id, so an application
// with no linked account cannot deep-link. // with no linked account cannot deep-link.
if (!c.userId) return if (!c.userId) return
openCandidateProfile(navigate, c.userId, candidates) navigate(`/candidate/${c.userId}`)
}} }}
> >
<div className="k-card-top"> <div className="k-card-top">

View File

@ -275,9 +275,6 @@ function blankForm() {
date_needed: '', date_needed: '',
type: '', type: '',
job_description: '', job_description: '',
period_from: '',
period_to: '',
jd_available: false,
to_replace: '', to_replace: '',
grade: '', grade: '',
recruitment_title: '', recruitment_title: '',
@ -287,7 +284,6 @@ function blankForm() {
recommended_grade: '', recommended_grade: '',
employee_name: '', employee_name: '',
employee_department: '', employee_department: '',
entity: '',
initiated_by: '', initiated_by: '',
initiated_date: '', initiated_date: '',
recommended_by: '', recommended_by: '',
@ -314,9 +310,6 @@ function fromRow(row) {
date_needed: toDateInput(pos.date_needed), date_needed: toDateInput(pos.date_needed),
type: pos.type || '', type: pos.type || '',
job_description: pos.job_description || '', job_description: pos.job_description || '',
period_from: toDateInput(pos.period_from),
period_to: toDateInput(pos.period_to),
jd_available: pos.jd_available === true,
to_replace: rep.to_replace || '', to_replace: rep.to_replace || '',
grade: rep.grade || '', grade: rep.grade || '',
recruitment_title: rep.title || '', recruitment_title: rep.title || '',
@ -326,7 +319,6 @@ function fromRow(row) {
recommended_grade: rep.recommended_grade || '', recommended_grade: rep.recommended_grade || '',
employee_name: ref.employee_name || '', employee_name: ref.employee_name || '',
employee_department: ref.employee_department || '', employee_department: ref.employee_department || '',
entity: ref.entity || '',
initiated_by: row.initiated_by || '', initiated_by: row.initiated_by || '',
initiated_date: toDateInput(row.initiated_date), initiated_date: toDateInput(row.initiated_date),
recommended_by: row.recommended_by || '', recommended_by: row.recommended_by || '',
@ -338,7 +330,7 @@ function fromRow(row) {
approved_by_svp: row.approved_by_svp === true, approved_by_svp: row.approved_by_svp === true,
approved_by_date_svp: toDateInput(row.approved_by_date_svp), approved_by_date_svp: toDateInput(row.approved_by_date_svp),
is_replacement: hasAny(rep, ['to_replace', 'grade', 'title', 'date_separated', 'justification', 'budget', 'recommended_grade']), is_replacement: hasAny(rep, ['to_replace', 'grade', 'title', 'date_separated', 'justification', 'budget', 'recommended_grade']),
is_referral: hasAny(ref, ['employee_name', 'employee_department', 'entity']), is_referral: hasAny(ref, ['employee_name', 'employee_department']),
} }
} }
@ -351,9 +343,6 @@ function toPayload(f) {
date_needed: emptyToNull(f.date_needed), date_needed: emptyToNull(f.date_needed),
type: emptyToNull(f.type), type: emptyToNull(f.type),
job_description: emptyToNull(f.job_description), job_description: emptyToNull(f.job_description),
period_from: emptyToNull(f.period_from),
period_to: emptyToNull(f.period_to),
jd_available: f.jd_available,
}, },
initiated_by: emptyToNull(f.initiated_by), initiated_by: emptyToNull(f.initiated_by),
initiated_date: emptyToNull(f.initiated_date), initiated_date: emptyToNull(f.initiated_date),
@ -388,12 +377,10 @@ function toPayload(f) {
? { ? {
employee_name: emptyToNull(f.employee_name), employee_name: emptyToNull(f.employee_name),
employee_department: emptyToNull(f.employee_department), employee_department: emptyToNull(f.employee_department),
entity: emptyToNull(f.entity),
} }
: { : {
employee_name: null, employee_name: null,
employee_department: null, employee_department: null,
entity: null,
}, },
} }
return body return body
@ -481,22 +468,6 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
))} ))}
</select> </select>
</div> </div>
<div className="form-field">
<label>If not permanent, period from</label>
<input
type="date"
value={fields.period_from}
onChange={(e) => set('period_from', e.target.value)}
/>
</div>
<div className="form-field">
<label>Period to</label>
<input
type="date"
value={fields.period_to}
onChange={(e) => set('period_to', e.target.value)}
/>
</div>
<div className="form-field col-span-2"> <div className="form-field col-span-2">
<label>Job description</label> <label>Job description</label>
<textarea <textarea
@ -506,16 +477,6 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('job_description', e.target.value)} onChange={(e) => set('job_description', e.target.value)}
/> />
</div> </div>
<div className="form-field">
<label>
<input
type="checkbox"
checked={fields.jd_available}
onChange={(e) => set('jd_available', e.target.checked)}
/>
{' '}JD available <span className="hf-note" style={{ margin: 0 }}>(mandatory sourcing will not start without it)</span>
</label>
</div>
</div> </div>
</div> </div>
@ -603,10 +564,6 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('employee_department', e.target.value)} onChange={(e) => set('employee_department', e.target.value)}
/> />
</div> </div>
<div className="form-field">
<label>Entity</label>
<input value={fields.entity} onChange={(e) => set('entity', e.target.value)} />
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,183 +1,219 @@
/* Candidate detail is a compact workspace that follows the app theme. /* Candidate detail is a focused, full-width workspace. Tokens stay scoped so
Previous / Next live in the page bar. There is no extra candidate list. */ returning to the rest of the ATS preserves the user's selected theme. */
.candidate-workspace .content { padding: 0 24px 28px; background: var(--bg); } .candidate-workspace[data-theme="dark"], .candidate-dialog {
html[data-theme="dark"] .candidate-workspace .content { background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); } color-scheme: dark;
.candidate-workspace .cand-page { max-width: 1680px; margin-inline: auto; font-size: 13px; min-width: 0; } --bg: #03171d;
.candidate-workspace .cand-page-bar { gap: 10px; min-height: 44px; margin: 0; padding-block: 8px; } --bg-elev: #071e26;
.candidate-workspace .cand-page-crumb { font-size: 12px; display: flex; align-items: center; min-width: 0; white-space: normal; overflow: visible; } --bg-sunken: #0c2933;
--border: #1b404b;
--border-strong: #315764;
--text: #edf7fa;
--text-2: #b6ced7;
--text-3: #9ebbc6;
--primary: #ccfa70;
--primary-600: #dcff98;
--primary-fg: #14210b;
--primary-soft: #ccfa7012;
--primary-border: #ccfa7040;
--accent-ink: #ccfa70;
--success: #25e9a5;
--success-soft: #07543866;
--warning: #ffd16e;
--warning-soft: #614d164d;
--danger: #ff7c86;
--danger-soft: #66222c66;
--info: #82bcff;
--info-soft: #123c6866;
--purple: #b6a6ff;
--purple-soft: #3e326666;
--teal: #25e9a5;
--teal-soft: #07543866;
--avatar-fg: #071720;
--shadow: none;
color: var(--text);
background: var(--bg);
}
.candidate-workspace .main-wrap { width: 100%; margin-left: 0; }
.candidate-workspace .content { padding: 0 42px 40px; background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
.candidate-workspace .topbar { min-height: 67px; height: auto; padding: 12px 42px; gap: 22px; background: #03181e; border-bottom: 1px solid var(--border); }
.candidate-brand { display: flex; align-items: center; gap: 13px; min-width: 230px; color: var(--text); text-decoration: none; }
.candidate-brand .brand-mark { width: 36px; height: 36px; fill: #25e9a5; }
.candidate-brand strong { display: block; font-size: 18px; line-height: 1.25; letter-spacing: -.4px; }
.candidate-brand small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.candidate-workspace .menu-toggle { display: inline-flex; order: -1; width: 32px; }
.candidate-workspace .topbar-search { max-width: 520px; }
.candidate-workspace .topbar-search input { height: 38px; border-radius: 8px; background: #0c2832; font-size: 13px; }
.candidate-workspace .profile-btn .avatar { background: #a19df5; color: #071720; }
.candidate-workspace .sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 60; transform: none; }
.candidate-workspace .cand-page { max-width: 1740px; margin-inline: auto; font-size: 13px; }
.candidate-workspace .cand-page-bar { gap: 16px; min-height: 58px; margin: 0; }
.candidate-workspace .cand-page-crumb { font-size: 12px; }
.candidate-workspace .cand-page-crumb span { margin: 0 8px; } .candidate-workspace .cand-page-crumb span { margin: 0 8px; }
.candidate-workspace .cand-page-crumb strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .candidate-workspace .cand-page-actions { width: auto; }
.candidate-workspace .cand-page-actions { width: auto; margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .candidate-workspace .btn, .candidate-dialog .btn { min-height: 36px; padding: 7px 12px; border-radius: 7px; gap: 8px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; }
.candidate-workspace .btn, .candidate-dialog .btn { min-height: 32px; padding: 5px 10px; border-radius: 7px; gap: 6px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; } .candidate-workspace .btn-secondary, .candidate-dialog .btn-secondary { border: 1px solid var(--border); color: var(--text); background: linear-gradient(120deg, #0c2730, #071e26); }
.candidate-workspace .btn-primary, .candidate-dialog .btn-primary { font-weight: 650; } .candidate-workspace .btn-secondary:hover:not(:disabled), .candidate-dialog .btn-secondary:hover:not(:disabled) { background: #13333d; border-color: #39606b; }
.candidate-workspace .btn-sm { min-height: 28px; padding: 4px 8px; font-size: 12px; } .candidate-workspace .btn-primary, .candidate-dialog .btn-primary { color: var(--primary-fg); border: 1px solid #c5ed6e; background: linear-gradient(105deg, #d3fd80, #c9f86b); font-weight: 650; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 15px; height: 15px; flex-shrink: 0; } .candidate-workspace .btn-primary:hover:not(:disabled), .candidate-dialog .btn-primary:hover:not(:disabled) { background: #dcff9b; }
.candidate-workspace .btn-sm { min-height: 30px; padding: 5px 8px; font-size: 12px; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.candidate-workspace :where(button, a, input, select, textarea):focus-visible, .candidate-dialog :where(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } .candidate-workspace :where(button, a, input, select, textarea):focus-visible, .candidate-dialog :where(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.candidate-workspace button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; } .candidate-workspace button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: var(--primary-border); } .candidate-workspace .star-btn.on { color: var(--primary); border-color: #788e49; }
.cw-browse-nav { display: flex; align-items: center; gap: 4px; } .cw-hero { display: flex; gap: 24px; padding: 22px 24px 20px; border: 1px solid var(--border); border-radius: 13px; background: linear-gradient(110deg, #09252e, #061d25 70%, #09252c); }
.cw-browse-arrow { width: 32px; padding: 5px; flex-shrink: 0; } .cw-hero .cw-avatar { width: 78px; height: 78px; font-size: 28px; flex-shrink: 0; }
.cw-browse-counter { min-width: 52px; text-align: center; font-size: 12px; color: var(--text-2); white-space: nowrap; }
.cw-hero { display: flex; gap: 16px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(110deg, var(--bg-elev), var(--bg-sunken) 70%, var(--bg-elev)); }
.cw-hero .cw-avatar { width: 56px; height: 56px; font-size: 20px; flex-shrink: 0; }
.cw-hero-body, .cw-identity { flex: 1; min-width: 0; } .cw-hero-body, .cw-identity { flex: 1; min-width: 0; }
.cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.cw-name { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; } .cw-name { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; }
.cw-name h1 { margin: 0; font-size: 22px; line-height: 1.2; letter-spacing: -.4px; font-weight: 650; overflow-wrap: anywhere; } .cw-name h1 { margin: 0; font-size: 28px; line-height: 1.2; letter-spacing: -.7px; font-weight: 650; overflow-wrap: anywhere; }
.cw-contact { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-3); font-size: 12px; } .cw-contact { display: flex; flex-wrap: wrap; gap: 9px 22px; color: var(--text-3); font-size: 12px; }
.cw-contact > * { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; } .cw-contact > * { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow-wrap: anywhere; }
.cw-contact a { color: var(--text-3); text-decoration: none; } .cw-contact a { color: var(--text-3); text-decoration: none; }
.cw-contact a:hover { color: var(--text); } .cw-contact a:hover { color: var(--text); }
.cw-contact .cw-external, .cw-info a { color: var(--info); text-decoration: underline; text-underline-offset: 3px; } .cw-contact .cw-external, .cw-info a { color: #9dd3f1; text-decoration: underline; text-underline-offset: 3px; }
.cw-contact svg { width: 14px; height: 14px; flex-shrink: 0; } .cw-contact svg { width: 16px; height: 16px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 8px; flex-shrink: 0; } .cw-hero-actions { display: flex; gap: 10px; flex-shrink: 0; }
.cw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr)); gap: 12px 0; margin-top: 14px; } .cw-facts { display: grid; grid-template-columns: 1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr; margin-top: 22px; }
.cw-hero, .cw-overview, .cw-card, .cw-column { min-width: 0; max-width: 100%; } .cw-fact { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 16px; border-left: 1px solid var(--border); }
.cw-fact { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 0 12px; border-left: 1px solid var(--border); }
.cw-fact:first-child { border-left: 0; padding-left: 0; } .cw-fact:first-child { border-left: 0; padding-left: 0; }
.cw-fact > svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; } .cw-fact:last-child { padding-right: 0; }
.cw-fact span { display: block; color: var(--text-3); font-size: 11px; line-height: 1.3; margin-bottom: 2px; } .cw-fact > svg { width: 20px; height: 20px; color: #c3dce4; flex-shrink: 0; }
.cw-fact strong { font-size: 12px; font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; } .cw-fact span { display: block; color: var(--text-3); font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.cw-fact strong { font-size: 13px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
.cw-tabs { min-width: 0; max-width: 100%; } .cw-tabs .tabs { gap: 10px; margin-bottom: 16px; }
.cw-tabs .tabs { gap: 4px; margin-bottom: 12px; min-width: 0; max-width: 100%; } .cw-tabs .tab { min-height: 55px; padding: 12px 20px; margin: 0; font-size: 13px; font-weight: 400; border-bottom-width: 3px; }
.cw-tabs .tab { min-height: 42px; padding: 8px 14px; margin: 0; font-size: 12.5px; font-weight: 400; border-bottom-width: 2px; }
.cw-tabs .tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; } .cw-tabs .tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.cw-tabs .tab-count { background: var(--bg-sunken); color: var(--text-2); font-size: 11px; min-width: 16px; } .cw-tabs .tab-count { background: #153941; color: #cbdee4; font-size: 11px; min-width: 18px; }
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .99fr); gap: 16px; align-items: start; }
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .95fr); gap: 12px; align-items: start; } .cw-column { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.cw-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; } .cw-card { min-width: 0; padding: 18px 17px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(120deg, #09232c, #061e26 90%); }
.cw-card { min-width: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: linear-gradient(120deg, var(--bg-elev), var(--bg-sunken) 90%); } .cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; } .cw-card-head h2 { font-size: 15px; font-weight: 650; letter-spacing: -.2px; line-height: 1.35; margin: 0; }
.cw-card-head h2 { font-size: 13px; font-weight: 650; letter-spacing: -.1px; line-height: 1.3; margin: 0; } .cw-link { display: inline-flex; align-items: center; gap: 6px; color: #b4ed91; text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
.cw-link { display: inline-flex; align-items: center; gap: 6px; color: var(--primary); text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; } .cw-link svg { width: 15px; height: 15px; }
.cw-link svg { width: 14px; height: 14px; } .cw-info { display: grid; gap: 15px; margin: 0; }
.cw-info { display: grid; gap: 10px; margin: 0; } .cw-info > div { display: grid; grid-template-columns: minmax(115px, .9fr) minmax(0, 1.4fr); gap: 12px; line-height: 1.4; font-size: 12px; }
.cw-info > div { display: grid; grid-template-columns: minmax(108px, .9fr) minmax(0, 1.4fr); gap: 8px; line-height: 1.4; font-size: 12px; } .cw-info dt { display: flex; align-items: flex-start; gap: 10px; color: var(--text-3); }
.cw-info dt { display: flex; align-items: flex-start; gap: 8px; color: var(--text-3); } .cw-info dt svg { width: 15px; height: 15px; margin-top: 1px; flex-shrink: 0; }
.cw-info dt svg { width: 14px; height: 14px; margin-top: 1px; flex-shrink: 0; }
.cw-info dd { margin: 0; overflow-wrap: anywhere; } .cw-info dd { margin: 0; overflow-wrap: anywhere; }
.cw-skills { display: flex; gap: 6px; flex-wrap: wrap; } .cw-skills { display: flex; gap: 8px; flex-wrap: wrap; }
.cw-skills > span { padding: 4px 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; max-width: 100%; overflow-wrap: anywhere; } .cw-skills > span { padding: 6px 10px; border: 1px solid #284b57; border-radius: 12px; background: #102d38; color: #e0edf3; font-size: 12px; max-width: 100%; overflow-wrap: anywhere; }
.cw-table-wrap { overflow: auto; min-width: 0; max-width: 100%; } .cw-table-wrap { overflow: auto; }
.cw-applications { width: 100%; border-collapse: collapse; font-size: 12px; text-align: left; } .cw-applications { width: 100%; border-collapse: collapse; font-size: 12px; text-align: left; }
.cw-applications th { color: var(--text-3); text-transform: uppercase; letter-spacing: .4px; font-size: 10px; font-weight: 500; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 7px 6px; white-space: nowrap; } .cw-applications th { color: #bad1dc; text-transform: uppercase; letter-spacing: .4px; font-size: 11px; font-weight: 500; border-top: 1px solid #15343d; border-bottom: 1px solid #15343d; padding: 9px 6px; white-space: nowrap; }
.cw-applications td { padding: 10px 6px; border-bottom: 1px solid var(--border); } .cw-applications td { padding: 13px 6px; border-bottom: 1px solid #15343d; }
.cw-applications td:first-child, .cw-applications th:first-child { padding-left: 0; } .cw-applications td:first-child, .cw-applications th:first-child { padding-left: 0; }
.cw-applications td:last-child, .cw-applications th:last-child { padding-right: 0; } .cw-applications td:last-child, .cw-applications th:last-child { padding-right: 0; }
.cw-applications tr:last-child td { border-bottom: 0; } .cw-applications tr:last-child td { border-bottom: 0; }
.cw-applications td:nth-child(2) { color: var(--text-2); white-space: nowrap; } .cw-applications td:nth-child(2) { color: var(--text-2); white-space: nowrap; }
.cw-applications strong { display: block; font-size: 12px; font-weight: 550; } .cw-applications strong { display: block; font-size: 13px; font-weight: 550; }
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 3px; } .cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-applications .badge { font-size: 11px; padding: 3px 7px; } .cw-applications .badge { font-size: 11px; padding: 3px 7px; }
.cw-applications .is-current { background: linear-gradient(90deg, var(--primary-soft), transparent); } .cw-applications .is-current { background: linear-gradient(90deg, #12353438, transparent); }
.cw-summary { margin: 0; color: var(--text-2); font-size: 12.5px; line-height: 1.65; white-space: pre-line; overflow-wrap: anywhere; } .cw-summary { margin: 0; color: var(--text-2); font-size: 13px; line-height: 1.8; white-space: pre-line; overflow-wrap: anywhere; }
.cw-summary p + p { margin-top: 8px; } .cw-summary p + p { margin-top: 10px; }
.cw-empty { color: var(--text-3); font-size: 12.5px; line-height: 1.6; margin: 0; } .cw-empty { color: var(--text-3); font-size: 13px; line-height: 1.7; margin: 0; }
.cw-document { display: flex; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, var(--bg-elev), var(--bg-sunken)); min-width: 0; } .cw-document { display: flex; align-items: center; gap: 11px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, #0d2c36, #0a232b); min-width: 0; }
.cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 26px; height: 32px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; } .cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 29px; height: 36px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; }
.cw-document-icon svg { width: 14px; height: 14px; } .cw-document-icon svg { width: 16px; height: 16px; }
.cw-document-icon small { font-size: 7px; line-height: 1.2; margin-top: 2px; } .cw-document-icon small { font-size: 7px; line-height: 1.2; margin-top: 2px; }
.cw-document-name { flex: 1; min-width: 0; } .cw-document-name { flex: 1; min-width: 0; }
.cw-document-name strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; } .cw-document-name strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.cw-document-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; } .cw-document-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-document-actions { display: flex; gap: 6px; flex-shrink: 0; } .cw-document-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cw-document-list { display: grid; gap: 8px; } .cw-document-list { display: grid; gap: 9px; }
.candidate-workspace .cw-icon-label { width: 28px; font-size: 0; gap: 0; } .candidate-workspace .cw-icon-label { width: 30px; font-size: 0; gap: 0; }
.cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 12px; } .cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 14px; }
.cw-bottom-grid > .cw-card { align-self: start; } .cw-bottom-grid > .cw-card { align-self: start; }
.cw-rating { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .cw-bottom-grid .cw-card { padding: 17px; }
.cw-rating { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cw-rating > span { font-size: 12px; color: var(--primary); } .cw-rating > span { font-size: 12px; color: var(--primary); }
.cw-rating .rating-stars .rs svg { width: 16px; height: 16px; } .cw-rating .rating-stars .rs svg { width: 18px; height: 18px; }
.cw-recruiter { display: flex; align-items: center; gap: 10px; } .cw-recruiter { display: flex; align-items: center; gap: 10px; }
.cw-recruiter .avatar { width: 28px; height: 28px; font-size: 11px; } .cw-recruiter .avatar { width: 32px; height: 32px; font-size: 12px; }
.cw-recruiter strong { display: block; font-size: 12px; font-weight: 500; } .cw-recruiter strong { display: block; font-size: 12px; font-weight: 500; }
.cw-recruiter small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; } .cw-recruiter small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } .cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 9px; }
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 7px 8px; white-space: normal; text-align: left; } .cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 8px; white-space: normal; text-align: left; }
.candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid var(--danger); color: var(--danger); background: var(--danger-soft); } .candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid #ae4a55; color: #ff7c86; background: #2a172055; }
.candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: var(--danger); color: var(--danger-fg); } .candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: #50232b; }
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -2px 0 10px; } .cw-active-application { font-size: 12px; color: var(--text-3); margin: -4px 0 12px; }
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 8px; } .cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 10px; }
.cw-field-label { display: block; color: var(--text-3); font-size: 11px; margin-bottom: 4px; } .cw-field-label { display: block; color: var(--text-3); font-size: 12px; margin-bottom: 5px; }
.cw-status-grid select, .cw-status-value { width: 100%; min-height: 34px; padding: 6px 8px; background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; } .cw-status-grid select, .cw-status-value { width: 100%; min-height: 37px; padding: 8px 10px; background: #0c2933; color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; }
.cw-status-value { display: flex; align-items: center; gap: 8px; } .cw-status-value { display: flex; align-items: center; gap: 8px; }
.cw-status-dot { width: 7px; height: 7px; background: var(--success); border-radius: 50%; flex-shrink: 0; } .cw-status-dot { width: 7px; height: 7px; background: var(--success); border-radius: 50%; flex-shrink: 0; }
.cw-status-dot.is-closed { background: var(--text-3); } .cw-status-dot.is-closed { background: var(--text-3); }
.cw-activity { list-style: none; margin: 0; padding: 0; } .cw-activity { list-style: none; margin: 0; padding: 0; }
.cw-activity li { position: relative; padding: 0 0 16px 22px; } .cw-activity li { position: relative; padding: 0 0 23px 24px; }
.cw-activity li:last-child { padding-bottom: 0; } .cw-activity li:last-child { padding-bottom: 0; }
.cw-activity li::before { content: ''; position: absolute; left: 0; top: 4px; width: 9px; height: 9px; background: var(--info); border: 2px solid var(--bg-elev); border-radius: 50%; z-index: 1; } .cw-activity li::before { content: ''; position: absolute; left: 0; top: 4px; width: 10px; height: 10px; background: #59a8ff; border: 2px solid #245788; border-radius: 50%; z-index: 1; }
.cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 14px; bottom: 2px; background: var(--border); } .cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 15px; bottom: 3px; background: #315662; }
.cw-activity-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .cw-activity-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.cw-activity strong { font-size: 12px; font-weight: 550; } .cw-activity strong { font-size: 12px; font-weight: 550; }
.cw-activity time { color: var(--text-3); font-size: 11px; white-space: nowrap; } .cw-activity time { color: var(--text-3); font-size: 11px; white-space: nowrap; }
.cw-activity p { color: var(--text-3); font-size: 12px; line-height: 1.55; margin: 4px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .cw-activity p { color: var(--text-3); font-size: 12px; line-height: 1.65; margin: 5px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cw-activity small { color: var(--text-3); font-size: 11px; } .cw-activity small { color: var(--text-3); font-size: 11px; }
.cw-screening { display: flex; align-items: center; gap: 14px; } .cw-screening { display: flex; align-items: center; gap: 18px; }
.cw-match { display: grid; justify-items: center; gap: 4px; flex-shrink: 0; } .cw-match { display: grid; justify-items: center; gap: 6px; flex-shrink: 0; }
.cw-match small { color: var(--text-3); font-size: 12px; } .cw-match small { color: var(--text-3); font-size: 12px; }
.cw-tab-content { padding: 16px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; } .cw-tab-content { padding: 22px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; }
.candidate-dialog { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); } .candidate-dialog { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); }
.candidate-dialog .form-field { margin-top: 16px; } .candidate-dialog .form-field { margin-top: 16px; }
.candidate-dialog .empty-state { padding: 20px; } .candidate-dialog .empty-state { padding: 20px; }
.cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; } .cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.cw-muted { color: var(--text-3); } .cw-muted { color: var(--text-3); }
/* Sidebar is 262px, so these fire at the same content width as a full-bleed 1279/900. */ @media (max-width: 1279px) {
@media (max-width: 1560px) { .candidate-workspace .content { padding-inline: 24px; }
.candidate-workspace .content { padding-inline: 18px; } .candidate-workspace .topbar { padding-inline: 24px; }
.candidate-workspace .topbar { padding-inline: 18px; } .candidate-brand { min-width: 200px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr); } .cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); }
.cw-column-info { grid-column: 1; grid-row: 1; } .cw-column-info { grid-column: 1; grid-row: 1; }
.cw-column-main { grid-column: 2; grid-row: 1 / span 2; } .cw-column-main { grid-column: 2; grid-row: 1 / span 2; }
.cw-column-actions { grid-column: 1; grid-row: 2; } .cw-column-actions { grid-column: 1; grid-row: 2; }
.cw-hero-top { flex-wrap: wrap; } .cw-hero-top { flex-wrap: wrap; }
.cw-hero-actions { margin-left: auto; } .cw-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 18px; }
.cw-fact:nth-child(5) { border-left: 0; padding-left: 0; }
.cw-bottom-grid { grid-template-columns: minmax(0, 1fr); } .cw-bottom-grid { grid-template-columns: minmax(0, 1fr); }
.cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 1fr; } .cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 1fr; }
} .cw-info > div { grid-template-columns: minmax(105px, .9fr) minmax(0, 1.4fr); }
@media (max-width: 1100px) {
.cw-overview { display: flex; flex-direction: column; }
.cw-overview > * { width: 100%; min-width: 0; }
.cw-column-actions { order: 0; }
.cw-hero { flex-wrap: wrap; }
.cw-hero-actions { width: 100%; flex-wrap: wrap; }
.cand-page-actions .star-btn { width: 32px; padding: 5px; font-size: 0; gap: 0; }
.cand-page-actions .star-btn svg { width: 15px; height: 15px; }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.candidate-workspace .content { padding: 0 12px 20px; } .candidate-workspace .content { padding: 0 16px 28px; }
.candidate-workspace .topbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; } .candidate-workspace .topbar { padding: 12px 16px; gap: 10px; flex-wrap: wrap; }
.candidate-brand { min-width: 0; flex: 1; gap: 9px; }
.candidate-brand strong { font-size: 16px; }
.candidate-brand .brand-mark { width: 30px; }
.candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; } .candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; }
.candidate-workspace .topbar-actions { margin-left: 0; } .candidate-workspace .topbar-actions { margin-left: 0; }
.candidate-workspace .topbar-divider, .candidate-workspace .profile-meta, .candidate-workspace .profile-btn .chev { display: none; } .candidate-workspace .topbar-divider, .candidate-workspace .profile-meta, .candidate-workspace .profile-btn .chev { display: none; }
.candidate-workspace .cand-page-bar { gap: 8px; padding-block: 6px; flex-wrap: wrap; } .candidate-workspace .cand-page-bar { gap: 10px; padding-block: 12px; }
.candidate-workspace .cand-page-crumb { flex: 1; min-width: 0; } .candidate-workspace .cand-page-crumb { flex: 1; }
.candidate-workspace .cand-page-crumb .cw-crumb-path { display: none; } .cw-hero { padding: 18px 16px; gap: 14px; flex-wrap: wrap; }
.candidate-workspace .cand-page-actions { width: 100%; margin-left: 0; justify-content: flex-end; flex-wrap: nowrap; } .cw-hero .cw-avatar { width: 54px; height: 54px; font-size: 22px; }
.candidate-workspace .cand-page-actions .btn { flex: 0 0 auto; } .cw-hero-body { display: contents; }
.candidate-workspace .cand-page-crumb .btn { flex-shrink: 0; } .cw-hero-top { flex: 1; min-width: 0; }
.cw-browse-nav { gap: 2px; } .cw-identity { flex-basis: 100%; }
.cw-browse-arrow { width: 30px; min-height: 30px; padding: 4px; } .cw-name { gap: 8px; }
.cw-browse-counter { min-width: 44px; font-size: 11px; } .cw-name h1 { font-size: 24px; }
.cw-hero { padding: 12px; gap: 12px; } .cw-hero-actions { flex-wrap: wrap; flex-shrink: 1; max-width: 100%; }
.cw-hero .cw-avatar { width: 44px; height: 44px; font-size: 16px; } .cw-facts { width: 100%; margin-top: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cw-name h1 { font-size: 18px; } .cw-fact { padding: 0 12px; gap: 9px; }
.cw-hero-actions .btn { flex: 1 1 auto; justify-content: center; } .cw-fact:nth-child(odd) { border-left: 0; padding-left: 0; }
.cw-facts { margin-top: 8px; }
.cw-fact { border-left: 0; padding: 8px 0 0; }
.cw-tabs .tabs { gap: 0; } .cw-tabs .tabs { gap: 0; }
.cw-tabs .tab { padding-inline: 12px; min-height: 40px; } .cw-tabs .tab { padding-inline: 16px; }
.cw-action-grid .btn { min-height: 40px; } .cw-overview { display: flex; flex-direction: column; }
.cw-info > div { grid-template-columns: 120px minmax(0, 1fr); } .cw-overview > * { width: 100%; }
.cw-applications { min-width: 360px; } .cw-column-actions { order: -1; }
.cw-action-grid .btn { min-height: 44px; }
.cw-info > div { grid-template-columns: 135px minmax(0, 1fr); font-size: 13px; }
.cw-applications { min-width: 470px; }
.cw-document { flex-wrap: wrap; } .cw-document { flex-wrap: wrap; }
.cw-document-name { min-width: 100px; } .cw-document-name { min-width: 100px; }
.cw-document-actions { margin-left: auto; } .cw-document-actions { margin-left: auto; }
.cw-bottom-grid > .cw-column { display: flex; } .cw-bottom-grid > .cw-column { display: flex; }
.cw-tab-content { padding: 12px; } .cw-tab-content { padding: 16px; }
.cw-screening { align-items: flex-start; } .cw-screening { align-items: flex-start; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {