Compare commits

...

21 Commits

Author SHA1 Message Date
ahmed.mujtaba 51bd8b56f2 Merge branch 'dev_main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Edit_Job_Profile 2026-09-14 21:05:36 +05:00
ahmed.mujtaba 1c4a6696cc Merge pull request 'Department_Module' (#93) from Department_Module into dev_main
Deploy to S3 / deploy (push) Successful in 37s Details
Reviewed-on: #93
2026-09-14 14:22:25 +00:00
ahmed.mujtaba 377be6d786 Merge branch 'dev_main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Department_Module 2026-09-14 19:19:51 +05:00
ahmed.mujtaba 433b2086b7 tested department 2026-09-14 19:12:02 +05:00
ahmed.mujtaba 3c0421c2ec job linkked successfully 2026-09-14 18:21:23 +05:00
ahmed.mujtaba f86c908076 job post and deaprtrment li kage 2026-09-14 18:13:27 +05:00
ahmed.mujtaba eee2071ac2 deapratment page and form working now linking depends 2026-09-14 17:57:09 +05:00
ahmed.mujtaba addbc77d2b . 2026-09-14 14:55:01 +05:00
ahmed.mujtaba ff1f91973d Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Department_Module 2026-09-14 13:53:46 +05:00
ahmed.mujtaba df684c421b . 2026-09-14 13:52:23 +05:00
ahmed.mujtaba 3e11d2812e . 2026-09-14 13:51:07 +05:00
ahmed.mujtaba 756023e304 Merge pull request 'MERGE_NEW_UI' (#92) from MERGE_NEW_UI into main
Deploy to S3 / deploy (push) Successful in 15m16s Details
Reviewed-on: #92
2026-09-11 13:41:41 +00:00
ahmed.mujtaba 331a4cb0fc Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into MERGE_NEW_UI 2026-09-11 18:38:56 +05:00
ahmed.mujtaba a021d60fc3 forms done 2026-09-11 18:37:16 +05:00
ahmed.mujtaba 80a3b290b9 forms done 2026-09-11 18:37:08 +05:00
Talha Ahmed 1f4ec4d451 Stop candidate profiles crashing on production payload shapes.
Deploy to S3 / deploy (push) Successful in 11m32s Details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 16:51:36 +05:00
ahmed.mujtaba 1eea85f47e . 2026-09-11 16:20:52 +05:00
Talha Ahmed 1ca15207c8 Merge origin/main into Talha.
Deploy to S3 / deploy (push) Successful in 2m20s Details
Keep main's candidate list/detail split and the compact profile prev/next UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:34:21 +05:00
Talha Ahmed 80eb5b3bbc Keep the app sidebar on candidate profiles and add compact Previous/Next browsing.
The profile was forcing its own chrome and a candidate list rail; recruiters now stay in the normal shell and step through the list they opened, with a denser layout that still fits a phone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:32:59 +05:00
sheheryarsoomro12 74bae5330e Add candidate workspace UI for the profile tab page.
Give the candidate route a dedicated dark workspace layout with overview actions, while keeping the drawer profile flow unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:09:19 +05:00
sheheryarsoomro12 45d82feeea Update .gitignore to include local macOS launcher files 2026-09-10 14:16:57 +05:00
60 changed files with 4361 additions and 119 deletions

9
.gitignore vendored
View File

@ -20,6 +20,10 @@ dist/**/*
.claude/
.audit.js
# Local macOS launcher (not shared — machine-specific)
Start.command
start.command
# Backups
.backup-prebrand/
*.bak
@ -88,4 +92,7 @@ frontend/dist/index.html
# nothing under either path that should be ignored.
frontend/dist/**
nginx.conf
smoke.test.mjs
smoke.test.mjs
**.docx
**.docs
Annex**

Binary file not shown.

Binary file not shown.

654
Main.dc.html Normal file
View File

@ -0,0 +1,654 @@
<!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>

835
RBAC_CONTEXT_PROMPT.md Normal file
View File

@ -0,0 +1,835 @@
# RBAC Context Prompt — HR-ATS-Portal (TalentFlow)
> Paste this whole file as context for an engineer or LLM that must reproduce this system's
> role-based access control exactly. Everything below is taken from the code on branch
> `Department_Module`. File references point back to the source of truth.
> Where the code has a quirk or gap, it is written down as-is under **Known behaviour to
> reproduce (or consciously fix)**. Do not tidy these away silently.
---
## 0. Your task
You are implementing an access-control layer that must behave **identically** to the one
described here. That means the same:
- data model (tags → bundles → roles → users),
- permission vocabulary (136 `module.action` tags),
- resolution algorithm (live, per request, deny-by-default),
- enforcement order and HTTP status codes and error strings,
- rules against privilege escalation,
- row-level data scoping (who sees which jobs, candidates, offers, requisitions),
- role-name-based business rules (tasks, assignments, hiring-manager portal),
- frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.
When this document and your instincts disagree, follow this document.
---
## 1. Core concepts in one paragraph
A **permission tag** is one atomic `module.action` string, such as `candidates.view`. A
**permission bundle** (table `permissions`) is a named JSONB array of tag ids. A **role** is a
named JSONB array of bundle ids. A **user** has zero or one role (`users.role_id`, nullable).
On every authenticated request the server walks role → bundles → tags and builds a flat list of
tag names. Route guards check that list. Service code then narrows which rows are visible
using a few helper predicates, some of which look at tags and some at the role name. Tags
never go into the JWT, so a permission change takes effect on the server on the very next
request.
---
## 2. Data model
PostgreSQL, schema `app`. SQLModel/SQLAlchemy async. Every table soft-deletes
(`is_deleted`) and has an `is_active` flag.
### 2.1 `permission_tags` — [backend/role/models.py](backend/role/models.py)
| column | type | notes |
|---|---|---|
| `id` | int PK | seed order matters: it sets matrix ordering and resolution ordering |
| `tag_name` | varchar(64) unique, indexed | `"{module}.{action}"` |
| `module` | varchar(32) indexed | |
| `action` | varchar(32) | |
| `description` | text null | |
| `is_active` / `is_deleted` | bool | |
| `created_at` / `updated_at` | timestamptz | |
Unique constraint `uq_permission_tags_module_action` on (`module`, `action`).
### 2.2 `permissions` (bundles)
| column | type | notes |
|---|---|---|
| `id` | int PK | |
| `name` | varchar(64) unique | |
| `description` | text null | |
| `permission_tags` | JSONB int[] | ids from `permission_tags.id`, **no FK** |
| `is_system` | bool | system bundles cannot be renamed |
| `is_active` / `is_deleted` / timestamps | | |
### 2.3 `roles`
| column | type | notes |
|---|---|---|
| `id` | int PK | |
| `role_name` | varchar(64) unique | free text (initial Alembic revision used an enum; the model is a varchar) |
| `description` | text null | |
| `permissions` | JSONB int[] | ids from `permissions.id`, **no FK** |
| `is_system` | bool | system roles cannot be renamed or deleted |
| `is_active` / `is_deleted` / timestamps | | |
### 2.4 `users` — [backend/users/models.py](backend/users/models.py)
Only the RBAC-relevant columns:
| column | type | notes |
|---|---|---|
| `id` | uuid PK | the JWT `sub` |
| `email` | unique | |
| `role_id` | int FK → `roles.id`, **nullable** | `role` relationship is `lazy="selectin"` |
| `is_active` | bool, default **false** | set true by email confirmation |
| `is_approved` | bool, default **false** | set true by an admin (or at admin creation) |
| `is_deleted` | bool | |
### 2.5 System role keys — `EnumRoles`
```
system_administrator hr_administrator recruiter hiring_manager
department_head interviewer ceo candidate
```
Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter,
4 hiring_manager, … , 8 candidate. **Some code hardcodes ids 4 and 8** (see §12).
Migration `026` soft-deletes `hr_administrator`, `interviewer` and `ceo` when no live user
holds them. The organisation runs four staff roles (`system_administrator`, `recruiter`,
`hiring_manager`, `department_head`) plus `candidate`. Migration `027` moves members of a
hand-made role named `Manager` onto `hiring_manager` and soft-deletes it. Code still accepts the
names `manager` and `admin` (see §6).
---
## 3. Permission vocabulary — 17 modules × 8 actions = 136 tags
Source: `PermissionModule`, `PermissionAction`, `PermissionTag` in
[backend/users/permissions.py](backend/users/permissions.py).
**Modules:** `dashboard, inbox, jobs, candidates, pipeline, department, interviews,
assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent,
requisitions`
**Actions:** `view, create, edit, delete, approve, export, manage, configure`
Rules:
1. `PermissionTag` is a `str` Enum listing every combination explicitly. At import time,
`_assert_vocabulary_complete()` raises `RuntimeError("PermissionTag vocabulary drift:
missing=[...] extra=[...]")` unless the enum equals the full modules × actions cross-product.
The server will not boot with a partial vocabulary.
2. Always serialise with `.value`. `f"{PermissionTag.X}"` renders the enum repr.
3. DB rows are seeded idempotently (`ON CONFLICT (tag_name) DO NOTHING`) by manual SQL
migrations: `001` (first 13 modules = 104 tags), `004` tasks, `007` talent,
`019` requisitions, `038` department. Comments in the code that say "104" or "120" tags are
stale. The real total is 136.
4. Two actions carry special meaning beyond "may use the screen":
- `*.manage` on `requisitions`, `candidates` and `offers` **removes row scoping**. See §6.
- `requisitions.configure` is an **opt-in to scoping**, not a screen permission. See §6.
---
## 4. Resolution algorithm — `Roles.resolve_tags(session, role)`
```
if role is None or not role.is_active or role.is_deleted: return ()
perm_ids = role.permissions
if not perm_ids or not a list: return ()
bundles = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
tag_ids = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
if not tag_ids: return ()
tags = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
sort tags by (id, tag_name); de-duplicate by tag_name keeping first
return tuple(tag_name ...)
```
Properties you must preserve:
- **Deny by default, never error.** Dangling ids, inactive or deleted bundles, and inactive or
deleted tags simply add nothing.
- **Union.** Tags from every attached bundle are merged. There are no negative grants.
- **Live.** It runs on every request inside `get_current_user`. Nothing is cached server-side
and nothing is put in the token.
- **Stable order.** The output is ordered by tag id, which is seed order.
---
## 5. Authentication and enforcement pipeline
### 5.1 Tokens — [backend/users/plugins.py](backend/users/plugins.py)
PyJWT HS256. Every token carries `sub`, `type`, `iat`, `exp`, `jti`.
`decode_token(token, expected_type=...)` rejects a token whose `type` does not match.
| type | default lifetime | extra claims |
|---|---|---|
| `access` | 30 min | `email`, `role_id` |
| `refresh` | 7 days | — |
| `reset` | 10 min | `crid` |
The token's `role_id` is informational only. Authorization always reloads the user from the DB.
There is no logout endpoint, no denylist and no `jti` tracking.
Login, signup, refresh and `/users/create` return:
`{access_token, refresh_token, token_type:"bearer", expires_in, data: <user without permissions>, status_code}`.
### 5.2 `get_current_user` (dependency alias `CurrentUser`)
The checks run in this order:
1. HTTP Bearer header is missing → FastAPI `HTTPBearer` returns **401** `"Not authenticated"` (FastAPI 0.136.1 behaviour).
2. Decode fails or type is not `access`**401** `"Could not validate credentials"` with `WWW-Authenticate: Bearer`.
3. Load the user by `sub` via `Users.get_user_by_id`. That query **excludes `role_id = 8` (candidate)**.
If the user is missing, `is_deleted`, or `!is_active`**401** `"User is inactive or does not exist"`.
4. `!is_approved`**403** `"Your Approval is at Pending"`.
5. `permissions = resolve_tags(user.role)`.
6. Return `serialize_user(user, with_permissions=True, permissions=...)`:
```json
{ "id": "uuid", "name": "...", "email": "...", "role_id": 3, "role_name": "recruiter",
"role_description": "...", "linkedin_url": null, "is_active": true, "is_approved": true,
"is_deleted": false, "created_at": "...", "updated_at": "...",
"permissions": ["dashboard.view", "..."] }
```
`GET /users/me` returns exactly this and requires only `CurrentUser`, with no tag. That way a user
with no role can still discover their state.
### 5.3 `require_permission(*tags, require_all=True)`
A FastAPI dependency factory that runs after `get_current_user`:
1. `current_user.role_id is None`**403** `"User has no role assigned"`. This check runs before any tag check.
2. `has_permission(granted, *tags, require_all)`:
- `require_all=True` → required ⊆ granted (AND)
- `require_all=False` → required ∩ granted ≠ ∅ (OR)
3. On failure → **403** with one of these exact details:
- one tag, AND: `"Missing required permission: candidates.view"`
- several tags, AND: `"Missing required permissions: a, b"`
- OR: `"Missing any of required permissions: a, b"`
4. On success it returns `current_user`, and handlers use it as `current_user: dict`.
A user whose role is soft-deleted or inactive still has a `role_id`. They pass step 1, resolve
to zero tags, and fail step 3.
### 5.4 Login and account-state rules — [backend/users/views.py](backend/users/views.py)
- `authenticate_user`: the email lookup excludes role 8. A bad email or password returns **401**
`"Incorrect email or password"`. Then `is_deleted` → 401 `"User is inactive"`, then
`!is_active` → 401 `"Please confirm your email address to activate your account"`, then
`!is_approved` → 403 `"Your Approval is at Pending"`.
- `refresh_access_token`: runs the same active, deleted and approved checks, with 401
`"Invalid or expired refresh token"` on a decode failure.
- **Signup** (`POST /users/signup`, public): hardcodes `role_id = 4` and `is_approved = false`,
lands with `is_active = false`, and emails a confirmation link that sets `is_active`.
An admin must then approve the account.
- **Admin create** (`POST /users/create`, needs `rbac_users.create`): sets `is_approved = true`.
`is_active` comes from the payload (default true). The escalation check in §5.5 applies.
- **Approval queue**: `GET /users/pending-approvals` (needs `settings.view`) lists users who are
active, not approved, not deleted and not role 8. `PUT /users/approve?record_id=` (needs
`rbac_users.edit`) returns 400 when the user is not active: `"User must confirm their email
before approval"`.
- **Candidate accounts (role 8)** cannot log in or resolve through `get_current_user`. They are
data records for applicants, not portal users.
### 5.5 Anti-escalation on role assignment — `User._check_role_assignment`
Used by `POST /users/create`, `PUT /users/assign-role` and `PUT /users/remove-role`. Both
assign and remove are also route-guarded by `rbac_users.edit`.
```
if new_role_id == existing_role_id: return # no-op, no checks
if 'rbac_users.manage' not in caller.perms: 403 "Assigning a role requires rbac_users.manage"
if new_role_id is None: return # removal needs only manage
role = roles[new_role_id]
if role missing or is_deleted: 404 "Role not found"
if not role.is_active: 400 "Role is not active"
missing = resolve_tags(role) - caller.perms
if missing: 403 "Cannot assign a role with permissions you do not hold: a, b"
```
So a caller can only hand out a role whose effective tags are a subset of their own.
---
## 6. Row-level scoping (data visibility on top of tags)
Tags decide **whether** a user may call an endpoint. These predicates decide **which rows**
they get back. They are pure functions of the `current_user` dict.
Backend: [backend/users/permissions.py](backend/users/permissions.py). The frontend mirror is
in [frontend/src/auth/permissions.js](frontend/src/auth/permissions.js) and must stay
byte-for-byte equivalent in logic.
```python
def is_hiring_manager(u): # "hiring-manager portal" user
return lower(strip(u.role_name)) in {"hiring_manager", "manager"}
def is_admin(u): # org-wide staff
return lower(strip(u.role_name)) in {"system_administrator", "hr_administrator", "admin"} \
or "requisitions.manage" in u.permissions
def sees_all_candidates(u):
return is_admin(u) or "candidates.manage" in u.permissions
def sees_all_offers(u):
return is_admin(u) or "offers.manage" in u.permissions
def scopes_to_own_requisitions(u): # evaluate in this exact order
if is_hiring_manager(u): return True # wins even over admin tags
if is_admin(u) or sees_all_candidates(u): return False
return "requisitions.configure" in u.permissions
```
Design rule stated in the code: **custom roles must be able to opt in through Access Control
tags. Never key scoping off `role_id`.** Role-name checks exist only for the seeded
hiring-manager and admin identities.
### 6.1 Job ownership sets — [backend/job/job_post/models.py](backend/job/job_post/models.py)
- `JobPosts.ids_for_manager(user_id)` returns non-deleted job posts where
`hiring_manager_id = user`, **unioned with** jobs whose `requisition_id` points at a
non-deleted requisition with `created_by = user`.
- `JobPosts.ids_for_creator(user_id, created_by=False)`:
- `created_by=True` returns jobs with `created_by = user`.
- Otherwise it returns jobs where the user is in `current_recruiter_ids` or is
`current_recruiter_id`, **or** (the job has no recruiters **and** `created_by = user`).
### 6.2 `owned_job_ids_for_candidate_scope(session, user, created_by=False)` — [backend/job/candidate/views.py](backend/job/candidate/views.py)
```
if scopes_to_own_requisitions(user): return ids_for_manager(user.id)
if sees_all_candidates(user): return None # None = unscoped
return ids_for_creator(user.id, created_by)
```
`job_post_ids_for_candidate_list` intersects a caller-requested job filter with that set.
`None` means unscoped and `[]` means nothing is visible.
### 6.3 `assert_manager_candidate_access(session, user, user_id|job_post_id|inbox_id|manual_id)`
```
if sees_all_candidates(user) and not scopes_to_own_requisitions(user): allow
owned = owned_job_ids_for_candidate_scope(...) or []
if not owned: 403 <scope detail>
resolve job_id (and candidate uid) from inbox_id / manual_id when not given
if job_id is None and uid is not None:
allow if any job the candidate is assigned to ∈ owned, else 403
if job_id not in owned: 403 <scope detail>
```
The scope detail is `MANAGER_SCOPE_DETAIL` when requisition-scoped and `CREATOR_SCOPE_DETAIL` otherwise.
### 6.4 Where scoping is applied
| Area | Rule |
|---|---|
| Candidates list / detail / applications / notes / forms | `owned_job_ids_for_candidate_scope` + `assert_manager_candidate_access` |
| `GET /candidate/fetch/users` and `/count` | Hiring-manager users get **403** `"Hiring managers can only list candidates on their requisitions"` |
| Candidate detail without `user_id` | Hiring manager → 403 `MANAGER_SCOPE_DETAIL` |
| Job posts list (`fetch_job_posts`) | If `scopes_to_own_requisitions`, restrict to `ids_for_manager`. Requested ids outside that set are dropped, and an empty set returns `[]` |
| Offers (candidate picker, create, sent) | `sees_all_offers` → unscoped. Otherwise use owned job ids, and an out-of-scope job returns 403 `"This offer is outside your assigned jobs"` |
| Requisition forms (`get_form_by_id`) | `is_admin` → all rows. Otherwise `created_by = me` |
| Candidate hiring forms list | Hiring manager with no `form_id`, `inbox_id`, `manual_upload_candidate_id` or `job_post_id` → 403 `"Hiring managers can only load forms for candidates on their requisitions"`. Otherwise `assert_manager_candidate_access` |
---
## 7. Role-name business rules (not tag-driven)
These rules look up role **names** and resolve ids from the `roles` table at request time.
| Rule | Where | Behaviour |
|---|---|---|
| Task creators | [backend/tasks/views.py](backend/tasks/views.py) | Route requires `tasks.create` **and** the caller's `role_id` must be one of the ids for `system_administrator`, `hr_administrator` or `recruiter`, else 403 `"Only system administrators, HR administrators and recruiters can create tasks"` |
| Task assignee | tasks | Must be an existing, non-deleted user with role `recruiter`, else 422 `"Tasks can only be assigned to recruiter accounts"`. Omitting the assignee is allowed only when the caller is a recruiter, who then self-assigns |
| Task assignee picker | `GET /tasks/assignees/fetch` (`tasks.view`) | All `recruiter` users, so the caller does not need `rbac_users.view` |
| Job assignment roles | [backend/job/assignment/views.py](backend/job/assignment/views.py) | `primary_recruiter` → user must hold `recruiter`; `hiring_manager` → user must hold `hiring_manager`. Otherwise 422 `"{field} must be a {role}"`. The user must also be active and not deleted |
| Application assignment | same | Assignee must be `recruiter` |
| Job post recruiters / HM | [backend/job/job_post/views.py](backend/job/job_post/views.py) | `current_recruiter_ids` must be recruiters; `hiring_manager_id` must be a hiring_manager |
| Inbox assign-recruiter | [backend/inbox/views.py](backend/inbox/views.py) | `recruiter_id` must be a `recruiter`, else 422 |
| Hiring-manager directory | `GET /managers/fetch` (`jobs.view OR candidates.view OR job_board.create`) | Users with role `hiring_manager`. Returns 500 if that role is not seeded |
| Recruiter performance | analytics | Iterates users whose role is `recruiter` |
| Admin notifications | [backend/notifications/views.py](backend/notifications/views.py) | Recipients are users with role `system_administrator` |
| Candidate identity | inbox / candidate / search models | Applicants are users with role `candidate` |
---
## 8. Seeded bundles and who gets them
The **initial** roles and the original bundle set, including `all_access` for
`system_administrator` (a fixed id list), were seeded outside this repo. Do not assume their
contents; export them (see §10). The manual migrations below are in the repo and all run
idempotently at startup via `alembic_setup.run_manual_sql()`.
| Bundle (`is_system=true`) | Tags | Attached to |
|---|---|---|
| `analytics_dashboard` (001) | all `dashboard.*`, `analytics.*`, `offers.*` + `interviews.view` | sysadmin, hr_admin, recruiter, hiring_manager, department_head, ceo |
| `tasks_management` (004) | all `tasks.*` | sysadmin, hr_admin, recruiter (005 removed it from hiring_manager, department_head, ceo) |
| `tasks_viewer` (005) | `tasks.view`, `tasks.export` | hiring_manager, department_head, ceo |
| `talent_sourcing` (007) | all `talent.*` | the six staff roles |
| `hiring_forms` (008) | `interviews.create`, `interviews.edit`, `interviews.delete` | the six staff roles |
| `requisitions_management` (019) | all `requisitions.*` (**includes `.manage` and `.configure`**) | the six staff roles |
| `manager_candidates` (024/025) | `candidates.view`, `candidates.create`, `candidates.edit` | hiring_manager (and a legacy `manager` role) |
| `requisitions_self` (028) | `requisitions.view`, `requisitions.create`, `requisitions.edit` | none. Meant for custom roles |
| `interviews_tab` (028) | `interviews.view`, `interviews.create`, `interviews.edit` | none. Meant for custom roles |
| `department_management` (038) | all `department.*` | sysadmin, hr_admin |
"The six staff roles" means `system_administrator, hr_administrator, recruiter, hiring_manager,
department_head, ceo`.
Pattern for adding a module (copy it exactly):
1. Add the module to `PermissionModule` **and** all 8 `PermissionTag` members (the startup assertion enforces this).
2. Add the module to `MODULES` in `frontend/src/auth/permissions.js`.
3. Write a manual SQL migration that inserts the 8 tags (`ON CONFLICT DO NOTHING`), creates a
`<module>_management` bundle with `jsonb_agg(id ORDER BY id)` over that module, and
appends the bundle id to the chosen roles guarded by
`NOT (permissions @> jsonb_build_array(id))`.
4. Guard the routes with `require_permission(PermissionTag.<MODULE>_<ACTION>)`.
5. Add the route to `frontend/src/app/routes.js` with `permission: '<module>.view'`.
6. Users must re-fetch `/users/me` (log in again) before the UI reflects the change.
**Consequence of the seed, if nobody has edited the matrix:** `requisitions_management` gives
`requisitions.manage` to recruiter, hiring_manager and department_head. `is_admin()` is
therefore true for recruiter and department_head, so they see every candidate, offer and
requisition. Hiring managers stay scoped only because `is_hiring_manager` is checked first in
`scopes_to_own_requisitions`. Verify this against the live export before relying on it.
---
## 9. Access Control editing (how grants change at runtime)
### 9.1 Endpoints — [backend/role/app.py](backend/role/app.py), [backend/role/views.py](backend/role/views.py)
| Method | Path | Tag | Behaviour |
|---|---|---|---|
| GET | `/roles/fetch[?record_id]` | `rbac_users.view` | Each role is expanded to `{..., permissions:[bundle ids], bundles:[bundle payloads with tag_names], effective_permissions:[resolved tag names]}` |
| POST | `/roles/create` | `rbac_users.create` | `role_name` required (400); duplicate → 409 `"Role name already exists"`; always `is_system=false` |
| PUT | `/roles/update?record_id` | `rbac_users.edit` | Partial update. Renaming a system role → 409 `"System roles cannot be renamed"`. May replace `permissions` (bundle ids) |
| DELETE | `/roles/delete?record_id` | `rbac_users.delete` | Soft delete. System role → 409 `"System roles cannot be deleted"` |
| PUT | `/roles/matrix/update?record_id` | `rbac_users.edit` | **Matrix save**, see §9.2 |
| GET | `/permissions/fetch` | `rbac_users.view` | Bundles with `tag_names` |
| POST | `/permissions/create` | `rbac_users.manage` | Always `is_system=false`; name clash → 409 |
| PUT | `/permissions/update?record_id` | `rbac_users.manage` | Renaming a system bundle → 409 |
| PUT | `/roles/permission-tags/update` | `rbac_users.manage` | Body `{id: bundleId, permission_tags:[...], name?, description?, is_active?}` sets the exact tag set on one shared bundle. Unknown or inactive tag ids → 422 `"Unknown or inactive permission tag ids: [...]"`. Every role holding the bundle is affected immediately |
| GET | `/permission-tags/fetch` | `rbac_users.view` | Ordered by module, action |
All list endpoints accept `search`, `top`, `skip` and return `{data, total, status_code}`.
404s: `"Role not found"`, `"Permission bundle not found"`, `"Permission tag not found"`.
### 9.2 Matrix save — `Role.set_role_matrix(role_id, tag_ids)`
```
role must exist and not be deleted (404)
tag_ids = sorted(unique(tag_ids))
unknown or inactive ids → 422 "Unknown or inactive permission tag ids: [...]"
overlay = bundle named f"role_{role.id}_matrix"
if overlay is missing: create it (is_system=false, description "Access Control matrix for {role_name}")
else: overwrite its permission_tags
role.permissions = [overlay.id] # REPLACES every other bundle on the role
return the role payload
```
Shared system bundles are never mutated by the matrix. After the first save, a role's grant is
exactly the ticked cells. The seeded bundles no longer apply to that role, even though they
still exist.
### 9.3 Access Control screen — [frontend/src/screens/Rbac.jsx](frontend/src/screens/Rbac.jsx)
- Route `rbac`, gated on `rbac_users.view`.
- The role list hides `role_name === 'candidate'`. System roles show a "System role" badge and
have no delete action.
- The matrix has rows = modules and columns = actions. Both are derived from `/permission-tags/fetch`
in first-seen (id) order. A cell renders only if that tag exists; otherwise it shows `·`.
- The draft starts from `role.effective_permissions`. Toggles are disabled without
`rbac_users.edit`. Save is enabled only when the draft differs; it maps names to ids and
calls `PUT /roles/matrix/update`.
- Tooltip help: `requisitions.configure` = "Limit Jobs and Candidates to requisitions this user
created. Independent of Create."; `candidates.manage` = "See every candidate, not only jobs
this user owns."; `requisitions.manage` = "Org-wide requisition list (admin)."
- The New/Edit Role form edits name, description, active flag and **bundle** picks, and shows a
live preview of the union of `tag_names` from the picked bundles.
Settings screen: the *Approvals* tab lists `/users/pending-approvals` and its Approve button
requires `rbac_users.edit`. The *Users* tab shows Pending / Awaiting approval / Active badges.
---
## 10. Export the live grants before mirroring
Seeds and matrix edits diverge over time. Take the effective role → tag map from the database
rather than from §8:
```sql
SELECT r.id, r.role_name, r.is_system, r.is_active, r.is_deleted,
string_agg(DISTINCT p.name, ', ') AS bundles,
count(DISTINCT t.id) AS tag_count,
string_agg(DISTINCT t.tag_name, ', ' ORDER BY t.tag_name) AS effective_tags
FROM app.roles r
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
LEFT JOIN app.permissions p
ON p.id = rp.pid::int AND p.is_active AND NOT p.is_deleted
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
LEFT JOIN app.permission_tags t
ON t.id = pt.tid::int AND t.is_active AND NOT t.is_deleted
WHERE r.is_active AND NOT r.is_deleted
GROUP BY r.id
ORDER BY r.id;
```
Also dump `app.permissions` (id, name, is_system, permission_tags) and `app.permission_tags`
(id, tag_name) **with their ids**. Bundle and role arrays reference ids, so the ids must be
preserved.
---
## 11. Frontend mirror (cosmetic gating; the server is authoritative)
### 11.1 Session and permission bootstrap — [frontend/src/auth/AuthProvider.jsx](frontend/src/auth/AuthProvider.jsx)
- The session (tokens + `data`) lives in `lib/tokenStore`. `GET /users/me` runs as a TanStack
Query (`staleTime` 5 min, `retry: false`) whenever an access token exists. Its result is merged
into the stored session so a page reload already has `permissions`.
- `status` is `anonymous` (no token), `error` (me failed), `authenticated` (me data or
cached permissions present), or `loading`.
- `can(tag)` comes from `makeCan(permissions)`: **a null or undefined tag is always allowed**,
otherwise it is set membership.
- Sign-in invalidates the `me` query so the previous user's permissions cannot leak. Sign-out
only clears client state. When a refresh fails, the user is sent to `/auth/login?expired=1`.
### 11.2 Route guard — [frontend/src/auth/RequireAuth.jsx](frontend/src/auth/RequireAuth.jsx)
`anonymous` → redirect to `/auth/login` (keeping `from`); `error``/auth/login?expired=1`;
`loading` → full-page spinner, so the nav does not flash; a `permission` the user lacks →
`<Forbidden/>` ("You don't have access to this page").
### 11.3 Route table — [frontend/src/app/routes.js](frontend/src/app/routes.js)
| path | permission | | path | permission |
|---|---|---|---|---|
| dashboard | `dashboard.view` | | interviews | `interviews.view` |
| inbox | `inbox.view` | | requisitions | `requisitions.view` |
| matching (hidden) | `candidates.view` | | assessments | `assessments.view` |
| jobs | `jobs.view` | | offers | `offers.view` |
| candidates | `candidates.view` | | managers | `jobs.view` |
| cvbank | `candidates.view` | | departments | `department.view` |
| pipeline | `pipeline.view` | | calendar | `interviews.view` |
| progress | `jobs.view` | | reports | `reports.view` |
| import | `candidates.create` | | analytics | `analytics.view` |
| jobboard | `job_board.view` | | aistudio | *null* |
| recruiterhub | `analytics.view` | | notifications | *null* |
| talent | `talent.view` | | rbac | `rbac_users.view` |
| tasks | `tasks.view` | | settings | `settings.view` |
| aiassistant | *null* | | help | *null* |
Candidate detail sub-routes in `App.jsx` also require `candidates.view`.
### 11.4 Sidebar — [frontend/src/app/Sidebar.jsx](frontend/src/app/Sidebar.jsx)
A route is shown when `!hidden && can(permission) && (!isHiringManager(user) ||
HIRING_MANAGER_NAV.has(path))`, where
`HIRING_MANAGER_NAV = {candidates, requisitions, interviews, calendar, help, aiassistant, aistudio, notifications}`.
A group heading renders only if at least one of its items survives.
### 11.5 Hiring-manager portal behaviour
- `Dashboard` redirects hiring managers to `/candidates`.
- `Candidates` renders `<HiringManagerCandidates/>` for them; other users get the scoped or
unscoped list via `seesAllCandidates` / `scopesToOwnRequisitions`.
- The Favorite button on the candidate profile is hidden for hiring managers.
### 11.6 In-screen action gating (examples to mirror)
`jobs.edit` / `jobs.delete` on Jobs; `job_board.create` for Post Job; `pipeline.edit` to move a
stage; `candidates.create` for notes and ATS re-run; `candidates.edit` for rating, favorite and
matching assignment; `interviews.create || candidates.create` to schedule an interview;
`offers.create` / `offers.edit` on the offer form; `assessments.create|edit|delete`;
`reports.create|delete|export`; `requisitions.create|edit`; `department.create|edit`;
`inbox.edit`; `talent.edit`; `settings.configure`; `rbac_users.edit` for approvals;
`tasks.view|edit` on Recruiter Hub.
Tasks "create" requires `can('tasks.create') && ['system_administrator','hr_administrator','recruiter'].includes(user.role_name)`.
---
## 12. Known behaviour to reproduce (or consciously fix)
Mirror these exactly unless you have been told to change them, and record any deviation.
1. **Unauthenticated data routes:** `GET /email/fetch` and `GET /inbox/fetch` have no auth
dependency. `GET /jobs/alias` is public.
2. **No escalation check on role or bundle edits:** a holder of `rbac_users.edit` can set any
tags on any role, **including their own**, through `/roles/update` or `/roles/matrix/update`.
A holder of `rbac_users.manage` can do the same through the bundle endpoints. Only user↔role
assignment is subset-checked (§5.5).
3. `POST /users/create` **without** `role_id` skips the `rbac_users.manage` check entirely.
4. Hardcoded ids: signup assigns `role_id = 4`. `Users.get_users`, `count_users`,
`get_user_by_id`, `get_user_by_email` and `get_pending_approvals` exclude `role_id = 8`.
Other code resolves roles by name.
5. Name-based identities are matched case-insensitively and include legacy aliases: `manager`
→ hiring manager, `admin` → admin.
6. `requisitions.manage` makes a user an **admin** for candidate, offer and requisition scoping,
not only for requisitions.
7. No token revocation. A refresh token stays valid for 7 days after sign-out. Permission
changes apply server-side on the next request, but the UI updates only after `/users/me`
is refetched (re-login).
8. Users on a deleted or inactive role can still log in, but they hold zero tags.
9. Route handlers wrap unexpected exceptions as `HTTPException(500, detail=str(e))`, and the
frontend may present any error as a permissions problem.
10. Comments in `frontend/src/auth/permissions.js` and `routes.js` saying enforcement is
"cosmetic, only /users/*, /roles/*, /permissions/* are enforced" are **stale**. As the
table below shows, almost every route is now guarded server-side.
11. `/email/sync` accepts either a JWT whose user holds `inbox.edit`, or a static
`CRON_INBOX_SYNC_TOKEN` compared in constant time, for the scheduler.
---
## 13. Acceptance checks for a faithful mirror
- The server refuses to boot if the tag enum is not the full modules × actions product.
- With no role → 403 `"User has no role assigned"` on any guarded route, while `/users/me` still returns 200.
- Deactivating a bundle removes its tags from every role on the very next request, with no re-login.
- Saving the matrix for role R creates or updates `role_R_matrix` and sets `R.permissions = [that id]`.
- Assigning a role that holds a tag the caller lacks → 403, and the message lists the missing tags.
- A hiring manager with every admin tag is still requisition-scoped.
- A custom role with `requisitions.create` alone is **not** scoped. Adding `requisitions.configure` scopes it; adding `candidates.manage` unscopes it.
- A recruiter without `candidates.manage` or `requisitions.manage` sees only jobs where they are a current recruiter, or which they created and which have no recruiters.
- Task creation by `department_head`, even when holding `tasks.create`, → 403.
- A frontend `can(null)` is true, and hiring managers see only the 8 locked nav items.
- The frontend predicate tests in [frontend/permissions-scope.test.mjs](frontend/permissions-scope.test.mjs) pass against your implementation.
---
## Appendix A — Every backend route and its guard
`AND` = `require_all=True`; `OR` = `require_all=False`. Row scoping (§6) and role-name rules
(§7) apply on top of these guards. Generated from the `@router` decorators in `backend/*/app.py`.
| Domain | Method | Path | Required |
|---|---|---|---|
| analytics | GET | `/analytics/kpis/fetch` | analytics.view |
| analytics | GET | `/analytics/funnel/fetch` | analytics.view |
| analytics | GET | `/analytics/hiring-trend/fetch` | analytics.view |
| analytics | GET | `/analytics/source-performance/fetch` | analytics.view |
| analytics | GET | `/analytics/applications-per-job/fetch` | analytics.view |
| analytics | POST | `/analytics/ask` | analytics.view |
| analytics | GET | `/analytics/recruiter-performance/fetch` | analytics.view |
| assessments | GET | `/assessments/fetch` | assessments.view |
| assessments | GET | `/assessments/counts` | assessments.view |
| assessments | POST | `/assessments/create` | assessments.create |
| assessments | PATCH | `/assessments/update` | assessments.edit |
| assessments | DELETE | `/assessments/delete` | assessments.delete |
| assessments | POST | `/assessments/remind` | assessments.edit |
| candidate_forms | GET | `/forms/requisition/search` | requisitions.view OR job_board.create OR jobs.create |
| candidate_forms | GET | `/forms/requisition/fetch` | requisitions.view |
| candidate_forms | POST | `/forms/requisition/create` | requisitions.create |
| candidate_forms | PATCH | `/forms/requisition/update` | requisitions.edit |
| candidate_forms | GET | `/forms/definitions` | interviews.view |
| candidate_forms | GET | `/forms/fetch` | interviews.view |
| candidate_forms | POST | `/forms/create` | interviews.create |
| candidate_forms | PATCH | `/forms/update` | interviews.edit |
| candidate_forms | DELETE | `/forms/delete` | interviews.delete |
| department | GET | `/department/fetch` | department.view |
| department | POST | `/department/create` | department.create |
| department | PUT | `/department/update` | department.edit |
| department | GET | `/department/heads/fetch` | department.create OR department.edit |
| forget_password | POST | `/users/forget-password` | PUBLIC |
| forget_password | POST | `/users/forget-password/verify-code` | PUBLIC |
| forget_password | POST | `/users/forget-password/new-password` | PUBLIC |
| g_sheet | GET | `/sheet/health` | PUBLIC |
| g_sheet | GET | `/sheet/metadata` | settings.view |
| g_sheet | GET | `/sheet/tabs` | settings.view |
| g_sheet | GET | `/sheet/fetch` | settings.view |
| g_sheet | POST | `/sheet/import` | settings.edit |
| g_sheet | POST | `/sheet/{tab}/import` | settings.edit |
| g_sheet | GET | `/sheet/import/fetch` | settings.view |
| g_sheet | GET | `/sheet/form-data/sheets` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/fetch` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/counts` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/count` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/{record_id}` | inbox.view OR settings.view |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/assign-job-post` | inbox.edit OR settings.edit |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/processing-state` | inbox.edit OR settings.edit |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/duplicate` | inbox.edit OR settings.edit |
| g_sheet | DELETE | `/sheet/form-data/{tab}/delete` | settings.delete |
| g_sheet | POST | `/sheet/{tab}/append` | settings.edit |
| g_sheet | PATCH | `/sheet/{tab}/update` | settings.edit |
| g_sheet | POST | `/sheet/{tab}/clear` | settings.edit |
| inbox | GET | `/email/fetch` | PUBLIC |
| inbox | POST | `/email/sync` | custom: inbox_sync_caller |
| inbox | GET | `/email/sync/fetch` | inbox.view |
| inbox | GET | `/inbox/fetch` | PUBLIC |
| inbox | POST | `/inbox/{record_id}/match` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/assign-job-post` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/assign-recruiter` | inbox.edit |
| inbox | POST | `/inbox/{record_id}/read` | inbox.edit |
| inbox | PATCH | `/inbox/read` | inbox.edit |
| inbox | PATCH | `/inbox/read-all` | inbox.edit |
| inbox | GET | `/inbox/{record_id}/read-status` | inbox.edit |
| inbox | GET | `/inbox/all-applications` | inbox.view |
| inbox | GET | `/inbox/all-applications/count` | inbox.view |
| inbox | GET | `/inbox/counts` | inbox.view |
| inbox | GET | `/inbox/triage` | inbox.view |
| inbox | PATCH | `/inbox/triage/{record_id}/override` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/processing-state` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/duplicate` | inbox.edit |
| inbox | POST | `/email/send` | inbox.edit |
| inbox | POST | `/email/reply` | inbox.edit |
| inbox | POST | `/inbox/rescan-on-hold` | inbox.edit |
| inbox | GET | `/inbox/rescan-on-hold` | inbox.view |
| interview | POST | `/interview/{interview_id}/calendar-event` | interviews.create |
| interview | PATCH | `/interview/{interview_id}/calendar-event/reschedule` | interviews.edit |
| interview | POST | `/interview/{interview_id}/calendar-event/cancel` | interviews.edit |
| job | GET | `/jobs/alias` | PUBLIC |
| job | POST | `/candidate/create/candidate` | candidates.create |
| job | GET | `/candidate/fetch/users` | candidates.view |
| job | GET | `/candidate/fetch/users/count` | candidates.view |
| job | POST | `/candidate/cv_upload` | candidates.create |
| job | POST | `/candidate/cv-bank/upload` | candidates.create |
| job | GET | `/candidate/cv-bank/fetch` | candidates.view |
| job | POST | `/candidate/cv-bank/score` | candidates.create |
| job | GET | `/candidate/cv-bank/suggestions` | candidates.view |
| job | GET | `/candidate/cv-bank/file` | candidates.view |
| job | DELETE | `/candidate/cv-bank/delete` | candidates.delete |
| job | GET | `/candidate/matching/fetch` | candidates.view |
| job | GET | `/candidate/matching/fetch_by_id` | candidates.view |
| job | POST | `/candidate/matching/assign` | candidates.edit |
| job | POST | `/candidate/inbox-match` | candidates.edit |
| job | POST | `/job/post-job` | job_board.create |
| job | POST | `/job/image/upload` | job_board.create OR jobs.edit |
| job | GET | `/job/image/fetch` | jobs.view OR job_board.view |
| job | POST | `/job/assist-field` | job_board.create OR jobs.edit |
| job | GET | `/job/buffer/channels` | job_board.view |
| job | POST | `/candidate/score` | candidates.create |
| job | POST | `/candidate/score_inbox` | candidates.create |
| job | POST | `/candidate/ats-rerun` | candidates.create |
| job | GET | `/candidate/scored/fetch` | candidates.view |
| job | GET | `/job/fetch` | job_board.view OR candidates.view OR talent.view |
| job | GET | `/job/stats/fetch` | jobs.view OR pipeline.view |
| job | GET | `/job/departments/fetch` | job_board.view OR candidates.view OR talent.view OR jobs.view |
| job | GET | `/jobs/requisition-statuses/fetch` | jobs.view |
| job | GET | `/jobs/status-history/fetch` | jobs.view |
| job | GET | `/jobs/fetch` | jobs.view |
| job | GET | `/jobs/export` | jobs.export |
| job | GET | `/candidate/fetch_by_id` | candidates.view |
| job | GET | `/candidate/manager/fetch` | candidates.view |
| job | GET | `/candidate/fetch` | candidates.view |
| job | GET | `/candidate/applications/fetch` | candidates.view |
| job | PATCH | `/candidate/update` | candidates.edit |
| job | GET | `/candidate/history/fetch` | candidates.view |
| job | GET | `/interview/fetch` | interviews.view OR candidates.view |
| job | POST | `/interview/create` | interviews.create OR candidates.create |
| job | PATCH | `/interview/update` | interviews.edit OR candidates.edit |
| job | GET | `/notes/fetch` | candidates.view |
| job | POST | `/notes/create` | candidates.create |
| job | PATCH | `/notes/update` | candidates.edit |
| job | GET | `/activity/fetch` | candidates.view |
| job | POST | `/activity/create` | candidates.create |
| job | GET | `/feedback/fetch` | candidates.view |
| job | POST | `/feedback/create` | candidates.create |
| job | PATCH | `/feedback/update` | candidates.edit |
| job | PATCH | `/candidate/stage` | pipeline.edit |
| job | GET | `/pipeline/candidates/fetch` | pipeline.view |
| job | GET | `/pipeline/candidate/score/fetch` | pipeline.view |
| job | GET | `/pipeline/transitions/fetch` | pipeline.view |
| job | GET | `/job/assignments/fetch` | jobs.view |
| job | POST | `/job/assignments/create` | jobs.edit |
| job | GET | `/candidate/assignments/fetch` | candidates.view |
| job | POST | `/candidate/assignments/create` | candidates.edit |
| job | GET | `/job/costs/fetch` | jobs.view |
| job | GET | `/job/costs/source-channels/fetch` | jobs.view |
| job | POST | `/job/costs/create` | jobs.edit |
| job | PATCH | `/jobs/update` | jobs.edit |
| job | DELETE | `/jobs/delete` | jobs.delete |
| job | PATCH | `/jobs/status` | jobs.edit |
| job | GET | `/feedback/templates/fetch` | candidates.view |
| job | POST | `/feedback/templates/create` | candidates.create |
| job | PATCH | `/feedback/templates/update` | candidates.edit |
| job | DELETE | `/feedback/templates/delete` | candidates.delete |
| job | GET | `/documents/download` | candidates.view |
| notifications | POST | `/users/confirm-email` | PUBLIC |
| notifications | POST | `/users/confirm-email/resend` | PUBLIC |
| notifications | GET | `/notifications/fetch` | any authenticated user |
| notifications | POST | `/notifications/{record_id}/read` | any authenticated user |
| notifications | POST | `/notifications/read-all` | any authenticated user |
| notifications | DELETE | `/notifications/delete` | any authenticated user |
| offer | GET | `/offers/fetch` | offers.view |
| offer | POST | `/offers/create` | offers.create |
| offer | PATCH | `/offers/update` | offers.edit |
| offer | POST | `/offers/issue` | offers.approve |
| offer | GET | `/offers/jobs/candidates/lists` | offers.view |
| offer | POST | `/offers/jobs/sent` | offers.create |
| org_settings | GET | `/org-settings/fetch` | settings.view |
| org_settings | PUT | `/org-settings/update` | settings.configure |
| org_settings | GET | `/org-settings/exclude-university/fetch` | settings.view |
| org_settings | POST | `/org-settings/exclude-university/create` | settings.configure |
| org_settings | POST | `/org-settings/exclude-university/create-batch` | settings.configure |
| org_settings | PATCH | `/org-settings/exclude-university/update` | settings.configure |
| org_settings | DELETE | `/org-settings/exclude-university/delete` | settings.configure |
| org_settings | GET | `/org-settings/exclude-company/fetch` | settings.view |
| org_settings | POST | `/org-settings/exclude-company/create` | settings.configure |
| org_settings | POST | `/org-settings/exclude-company/create-batch` | settings.configure |
| org_settings | PATCH | `/org-settings/exclude-company/update` | settings.configure |
| org_settings | DELETE | `/org-settings/exclude-company/delete` | settings.configure |
| reports | GET | `/reports/fetch` | reports.view |
| reports | POST | `/reports/create` | reports.create |
| reports | PATCH | `/reports/update` | reports.edit |
| reports | DELETE | `/reports/delete` | reports.delete |
| reports | POST | `/reports/run` | reports.view |
| reports | GET | `/reports/export` | reports.export |
| reports | GET | `/reports/runs/fetch` | reports.view |
| role | GET | `/roles/fetch` | rbac_users.view |
| role | POST | `/roles/create` | rbac_users.create |
| role | PUT | `/roles/update` | rbac_users.edit |
| role | DELETE | `/roles/delete` | rbac_users.delete |
| role | GET | `/permissions/fetch` | rbac_users.view |
| role | POST | `/permissions/create` | rbac_users.manage |
| role | PUT | `/permissions/update` | rbac_users.manage |
| role | PUT | `/roles/matrix/update` | rbac_users.edit |
| role | PUT | `/roles/permission-tags/update` | rbac_users.manage |
| role | GET | `/permission-tags/fetch` | rbac_users.view |
| s3 | GET | `/s3/health` | PUBLIC |
| s3 | POST | `/s3/upload` | candidates.create OR settings.edit |
| s3 | GET | `/s3/url` | candidates.view OR settings.view |
| s3 | GET | `/s3/open` | candidates.view OR settings.view OR inbox.view |
| s3 | GET | `/s3/download` | candidates.view OR settings.view OR inbox.view |
| s3 | POST | `/s3/delete` | candidates.delete OR settings.delete |
| saved_search | GET | `/saved-searches/fetch` | any authenticated user |
| saved_search | POST | `/saved-searches/create` | any authenticated user |
| saved_search | PATCH | `/saved-searches/update` | any authenticated user |
| saved_search | DELETE | `/saved-searches/delete` | any authenticated user |
| search | GET | `/search/fetch` | jobs.view OR candidates.view |
| talent | POST | `/talent/runs/start` | talent.create |
| talent | GET | `/talent/runs/status` | talent.view |
| talent | GET | `/talent/account` | talent.view |
| talent | GET | `/talent/runs/fetch` | talent.view |
| talent | GET | `/talent/profiles/fetch` | talent.view |
| talent | GET | `/talent/profiles/fetch_by_id` | talent.view |
| talent | PATCH | `/talent/profiles/outreach` | talent.edit |
| talent | DELETE | `/talent/profiles/delete` | talent.delete |
| tasks | GET | `/tasks/fetch` | tasks.view |
| tasks | GET | `/tasks/assignees/fetch` | tasks.view |
| tasks | POST | `/tasks/create` | tasks.create |
| tasks | PATCH | `/tasks/update` | tasks.edit |
| tasks | DELETE | `/tasks/delete` | tasks.delete |
| users | POST | `/users/login` | PUBLIC |
| users | POST | `/users/signup` | PUBLIC |
| users | POST | `/users/refresh` | PUBLIC |
| users | GET | `/users/me` | any authenticated user |
| users | POST | `/users/create` | rbac_users.create |
| users | GET | `/users/pending-approvals` | settings.view |
| users | PUT | `/users/approve` | rbac_users.edit |
| users | GET | `/users/fetch` | rbac_users.view |
| users | PUT | `/users/update` | rbac_users.edit |
| users | PUT | `/users/assign-role` | rbac_users.edit |
| users | PUT | `/users/remove-role` | rbac_users.edit |
| users | DELETE | `/users/delete` | rbac_users.delete |
| users | GET | `/managers/fetch` | jobs.view OR candidates.view OR job_board.create |

View File

@ -99,6 +99,22 @@ async def search_requisitions(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/forms/requisition/open-count")
async def count_open_requisitions(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
session:AsyncSession=Depends(get_session),
):
"""Open requisitions: unlinked, or linked to a job post that is still open."""
try:
service=RequisitionForm(session=session)
data=await service.count_open(current_user)
return JSONResponse(content={"data":{"open":data},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/forms/requisition/fetch")
async def fetch_requisition_form(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),

View File

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

View File

@ -2,12 +2,11 @@ import uuid
from datetime import datetime, date as Date, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
from candidate_forms.enums import EmploymentType
if TYPE_CHECKING:
from job.job_post.models import JobPosts
@ -34,9 +33,13 @@ class Requisition(SQLModel, table=True):
),
)
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_department: Optional[str] = None
entity: Optional[str] = None
to_replace: Optional[str] = None
grade: Optional[str] = None
@ -127,6 +130,32 @@ class Requisition(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_open(cls, session: AsyncSession, created_by=None) -> int:
"""Open requisitions: not linked to a live job post, or linked to one whose
requisition_status is still open. A closed / on-hold / completed job closes
its requisition. job_posts.requisition_id is 1:1, so the join never fans out.
"""
from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts
statement = (
select(func.count())
.select_from(cls)
.outerjoin(
JobPosts,
and_(JobPosts.requisition_id == cls.id, JobPosts.is_deleted == False), # noqa: E712
)
.where(
cls.is_deleted == False, # noqa: E712
or_(JobPosts.id.is_(None), JobPosts.requisition_status == RequisitionStatus.OPEN.value),
)
)
if created_by is not None:
statement = statement.where(cls.created_by == created_by)
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
position = fields.get("position") if fields.get("position") else {}
@ -139,8 +168,12 @@ class Requisition(SQLModel, table=True):
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,
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_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,
grade=replacement.get("grade") if replacement.get("grade") else None,
recruitment_title=replacement.get("title") if replacement.get("title") else None,
@ -183,6 +216,12 @@ class Requisition(SQLModel, table=True):
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
if "job_description" in position:
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:
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
if "to_replace" in replacement:
@ -205,6 +244,8 @@ class Requisition(SQLModel, table=True):
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
if "employee_department" in referral:
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:
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
if "initiated_date" in fields:

View File

@ -80,7 +80,8 @@ FORM_DEFINITIONS = {
"criteria": [
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
{"key": "relevant_experience", "label": "Depth of Relevant Experience"},
{"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
{"key": "problem_solving", "label": "Problem Solving"},
{"key": "analytical_reasoning", "label": "Analytical Reasoning"},
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
],
@ -109,27 +110,41 @@ FORM_DEFINITIONS = {
),
"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": {
"title": "Cultural Fit",
"title": "HR Evaluation",
"source": "Annexure E - Interview Evaluation Form",
"scale_note": RATING_SCALE_NOTE,
"sections": [
{
"key": "cultural",
"title": "CULTURAL FIT",
"average_label": "CULTURAL FIT SECTION",
"title": "HR EVALUATION",
"average_label": "HR EVALUATION SECTION",
"criteria": [
{"key": "company_values", "label": "Alignment with Company Values"},
{"key": "basic_jd_requirement", "label": "Basic JD requirement"},
{"key": "company_values", "label": "Alignment with Company Culture"},
{"key": "professionalism", "label": "Professionalism & Integrity"},
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
{"key": "adaptability", "label": "Adaptability to Change"},
{"key": "work_ethic", "label": "Work Ethic & Reliability"},
{"key": "adaptability", "label": "Adaptability"},
{"key": "agility", "label": "Agility"},
{"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": (
_EVALUATION_HEADER_FIELDS
+ [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
+ [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}]
+ _EVALUATION_FOOTER_FIELDS
),
"has_recommendation": True,
@ -174,6 +189,7 @@ FORM_DEFINITIONS = {
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "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_date", "label": "Initiated By — Date", "kind": "date"},
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
@ -354,7 +370,10 @@ def combined_summary(rows):
`rows` are candidate_forms records (attribute access: form_type, created_at,
sections). The latest interview_analysis row supplies the technical and
behavioral averages, the latest cultural_fit row the cultural average.
behavioral averages, the latest cultural_fit row the "cultural" section
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
ranged onto 0100) appears only once all three exist. Returns None when
neither evaluation exists. Legacy 14 section averages are converted

View File

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

View File

@ -435,6 +435,11 @@ class RequisitionForm:
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows]
async def count_open(self, current_user):
# Same scope as the requisition list: admins count every row, others their own.
created_by = None if is_admin(current_user) else _user_id(current_user)
return await Requisition.count_open(self.session, created_by=created_by)
async def search(self, q, top=50, job_post_id=None):
rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,

162
backend/department/app.py Normal file
View File

@ -0,0 +1,162 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from db_setup import get_session
from department.views import DepartmentService
from users.permissions import PermissionTag, require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
short_code: str = Field(min_length=1, max_length=10)
subtitle: str | None = Field(default=None, max_length=160)
description: str | None = None
is_active: bool = True
department_head_id: uuid.UUID | None = None
parent_department_id: uuid.UUID | None = None
location: list[str] = Field(default_factory=list)
class DepartmentUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
short_code: str | None = Field(default=None, min_length=1, max_length=10)
subtitle: str | None = Field(default=None, max_length=160)
description: str | None = None
is_active: bool | None = None
department_head_id: uuid.UUID | None = None
parent_department_id: uuid.UUID | None = None
location: list[str] | None = None
@router.get("/department/fetch")
async def fetch_departments(
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_VIEW)),
record_id: str | None = Query(None),
search: str | None = Query(None),
is_active: bool | None = Query(None),
top: int | None = Query(None, ge=1),
skip: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
if record_id is not None:
item = await service.get_department(record_id)
return JSONResponse(content={"data": item, "total": 1, "status_code": 200})
items, total = await service.get_departments(top, skip, search, is_active)
return JSONResponse(content={"data": items, "total": total, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/department/create")
async def create_department(
body: DepartmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
item = await service.create_department(body.model_dump(), current_user.get("id"))
return JSONResponse(status_code=201, content={"data": item, "status_code": 201})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/department/update")
async def update_department(
body: DepartmentUpdate,
record_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
item = await service.update_department(
record_id, body.model_dump(exclude_unset=True), current_user.get("id")
)
return JSONResponse(content={"data": item, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/heads/fetch")
async def fetch_department_heads(
role_id:Optional[int] = Query(5),
top: Optional[int]=Query(None, ge=1),
skip: Optional[int]=Query(0, ge=0),
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_CREATE,
PermissionTag.DEPARTMENT_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_head_options(role_id,top,skip,search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/locations/fetch")
async def fetch_department_locations(
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_CREATE,
PermissionTag.DEPARTMENT_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_location_options(search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/names")
async def fetch_department_names(
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_VIEW,
PermissionTag.JOB_BOARD_CREATE,
PermissionTag.JOBS_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_department_names(search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,174 @@
from uuid import UUID
import uuid
from datetime import datetime
from typing import Optional, TYPE_CHECKING, List
from sqlalchemy import DateTime, func, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
from sqlmodel import Field, Relationship, SQLModel, select
from department.plugins import as_uuid, now_utc
from users.models import Users
if TYPE_CHECKING:
from job.job_post.models import JobPosts
class Department(SQLModel, table=True):
__tablename__ = "departments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True, unique=True)
short_code: str = Field(index=True, unique=True, max_length=10)
# noload: selectin here would load every job post of a department whenever any job
# post loads its department_ref. Query JobPosts by department_id instead.
job_posts: List["JobPosts"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"})
subtitle: Optional[str] = Field(default=None)
description: Optional[str] = Field(default=None)
is_active: bool = Field(default=True)
parent_department_id: Optional[uuid.UUID] = Field(
default=None, index=True, foreign_key="departments.id"
)
department_head_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
location: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}
)
created_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
created_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
updated_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
@classmethod
async def get_department_names(cls, session: AsyncSession, search: str | None):
statement = select(cls.id, cls.name).where(cls.is_active == True)
if search:
pattern = f"%{search}%"
statement = statement.where(
or_(
cls.name.ilike(pattern),
cls.short_code.ilike(pattern),
cls.subtitle.ilike(pattern),
)
)
statement = statement.order_by(cls.created_at.desc(),cls.id.desc())
result = await session.execute(statement)
return result.all()
@classmethod
def _filters(cls, search: str | None, is_active: bool | None):
clauses = []
if search:
pattern = f"%{search}%"
clauses.append(
or_(
cls.name.ilike(pattern),
cls.short_code.ilike(pattern),
cls.subtitle.ilike(pattern),
)
)
if is_active is not None:
clauses.append(cls.is_active == is_active)
return clauses
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id) -> "Department | None":
uid = as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_departments(
cls,
session: AsyncSession,
top: int | None = None,
skip: int = 0,
search: str | None = None,
is_active: bool | None = None,
) -> list["Department"]:
statement = select(cls).where(*cls._filters(search, is_active)).order_by(cls.name)
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_departments(
cls,
session: AsyncSession,
search: str | None = None,
is_active: bool | None = None,
) -> int:
statement = select(func.count()).select_from(cls).where(*cls._filters(search, is_active))
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod
async def _commit(cls, session: AsyncSession, department: "Department") -> "Department":
"""Name/short-code uniqueness is the DB's unique indexes; IntegrityError propagates."""
session.add(department)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise
await session.refresh(department)
return department
@classmethod
async def insert_department(cls, session: AsyncSession, fields: dict) -> "Department":
return await cls._commit(session, cls(**fields))
@classmethod
async def update_department(
cls, session: AsyncSession, record_id, fields: dict
) -> "Department | None":
department = await cls.get_by_id(session, record_id)
if not department:
return None
for key, value in fields.items():
setattr(department, key, value)
department.updated_at = now_utc()
return await cls._commit(session, department)
# @classmethod
# async def head_options(cls, session: AsyncSession):
# """Active users for the Department Head picker. COLUMN select, not the Users entity."""
# result = await session.execute(
# select(Users.id, Users.name, Users.email)
# .where(Users.is_deleted == False, Users.is_active == True) # noqa: E712
# .order_by(Users.name)
# )
# return result.all()
@classmethod
async def job_posts_for(cls, session: AsyncSession, department_ids):
"""(department_id, job_post_id, requisition_status) for non-deleted job posts
linked to these departments through job_posts.department_id.
"""
from job.job_post.models import JobPosts
ids = [i for i in (department_ids or []) if i]
if not ids:
return []
result = await session.execute(
select(JobPosts.department_id, JobPosts.id, JobPosts.requisition_status)
.where(JobPosts.department_id.in_(ids), JobPosts.is_deleted == False) # noqa: E712
)
return result.all()
@classmethod
async def names_by_ids(cls, session: AsyncSession, ids) -> dict[uuid.UUID, str]:
uids = {i for i in (ids or []) if i}
if not uids:
return {}
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids)))
return {row[0]: row[1] for row in result.all()}

View File

@ -0,0 +1,50 @@
import uuid
from datetime import datetime, timezone
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def as_uuid(record_id) -> uuid.UUID | None:
"""Parse a UUID from any id-ish value; None when blank or malformed."""
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
def location_options(search=None) -> list[str]:
"""`{City} - {Country}` for every city in global_cities.Countries, optionally filtered."""
from global_cities import Countries
term = (search or "").strip().lower()
seen = set()
options = []
for country, cities in Countries.items():
for city in cities:
label = f"{city} - {country}"
if label in seen or (term and term not in label.lower()):
continue
seen.add(label)
options.append(label)
return options
def department_job_metrics(job_rows, applicants_by_job) -> dict:
"""Roll job-level rows up to {department_id: {job_posts, open_roles, candidates}}.
`job_rows` are (department_id, job_post_id, requisition_status); `applicants_by_job`
maps str(job_post_id) -> unique applicants on that job. Open roles are job posts
whose requisition_status is "open", the same meaning analytics uses.
"""
metrics = {}
for department_id, job_post_id, requisition_status in job_rows or []:
m = metrics.setdefault(department_id, {"job_posts": 0, "open_roles": 0, "candidates": 0})
m["job_posts"] += 1
if requisition_status == "open":
m["open_roles"] += 1
m["candidates"] += int(applicants_by_job.get(str(job_post_id), 0))
return metrics

View File

@ -0,0 +1,38 @@
from department.models import Department
def serialize_head_option(user) -> dict:
return {"id": str(user.id), "name": user.name, "email": user.email}
def serialize_department(
department: Department,
*,
head_name: str | None = None,
parent_name: str | None = None,
metrics: dict | None = None,
) -> dict:
return {
"id": str(department.id),
"name": department.name,
"short_code": department.short_code,
"subtitle": department.subtitle,
"description": department.description,
"is_active": department.is_active,
"parent_department_id": (
str(department.parent_department_id) if department.parent_department_id else None
),
"parent_department_name": parent_name,
"department_head_id": (
str(department.department_head_id) if department.department_head_id else None
),
"department_head_name": head_name,
"location": list(department.location or []),
"job_posts": (metrics or {}).get("job_posts", 0),
"open_roles": (metrics or {}).get("open_roles", 0),
"candidates": (metrics or {}).get("candidates", 0),
"created_by": str(department.created_by) if department.created_by else None,
"updated_by": str(department.updated_by) if department.updated_by else None,
"created_at": department.created_at.isoformat() if department.created_at else None,
"updated_at": department.updated_at.isoformat() if department.updated_at else None,
}

View File

@ -0,0 +1,77 @@
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from department.models import Department
from department.plugins import as_uuid,department_job_metrics,location_options
from department.serializers import serialize_department,serialize_head_option
from job.job_post.models import JobPosts
from users.models import Users
def serialize_department_name(row):
return {"id":str(row.id),"name":row.name}
class DepartmentService:
def __init__(self,session:AsyncSession):
self.session=session
async def get_department_names(self,search):
rows=await Department.get_department_names(self.session,search)
return [serialize_department_name(row) for row in rows]
async def _serialize_many(self,departments):
head_names=await Users.names_by_ids(self.session,[d.department_head_id for d in departments])
parent_names=await Department.names_by_ids(self.session,[d.parent_department_id for d in departments])
job_rows=await Department.job_posts_for(self.session,[d.id for d in departments])
job_ids=list({str(r[1]) for r in job_rows})
applicants={}
if job_ids:
stats,_=await JobPosts.fetch_job_stats(self.session,ids=job_ids)
applicants={str(row["job_post_id"]):row["total_applicants"] for row in stats}
metrics=department_job_metrics(job_rows,applicants)
return [
serialize_department(
d,
head_name=head_names.get(str(d.department_head_id)),
parent_name=parent_names.get(d.parent_department_id),
metrics=metrics.get(d.id),
)
for d in departments
]
async def get_department(self,record_id):
department=await Department.get_by_id(self.session,record_id)
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return (await self._serialize_many([department]))[0]
async def get_departments(self,top,skip,search,is_active):
departments=await Department.get_departments(self.session,top,skip,search,is_active)
total=await Department.count_departments(self.session,search,is_active)
return await self._serialize_many(departments),total
async def create_department(self,payload,user_id):
actor=as_uuid(user_id)
fields={**payload,"created_by":actor,"updated_by":actor}
try:
department=await Department.insert_department(self.session,fields)
except IntegrityError as e:
raise HTTPException(status_code=409,detail=str(e.orig)) from e
return (await self._serialize_many([department]))[0]
async def update_department(self,record_id,payload,user_id):
fields={**payload,"updated_by":as_uuid(user_id)}
try:
department=await Department.update_department(self.session,record_id,fields)
except IntegrityError as e:
raise HTTPException(status_code=409,detail=str(e.orig)) from e
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return (await self._serialize_many([department]))[0]
async def get_head_options(self,role_id,top,skip,search):
rows=await Users.get_users(self.session,top,skip,search,role_id)
return [serialize_head_option(row) for row in rows]
async def get_location_options(self,search):
return location_options(search)

View File

@ -143,6 +143,7 @@ class HiringCostCreate(BaseModel):
class JobUpdate(BaseModel):
title: str | None = None
department: str | None = None
department_id: UUID | None = None
location: str | None = None
employment_type: str | None = None
vacancies: int | None = None

View File

@ -12,6 +12,7 @@ from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from candidate_forms.models import Requisition
from department.models import Department
from users.models import Users
@ -25,6 +26,11 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
# Many job posts -> one department. `department` (below) stays the free-text name that
# analytics / filters / talent pool key off; set both together (see JobPost views).
# The relationship is `department_ref` because `department` is already that column.
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id", index=True)
department_ref: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
user: Optional["Users"] = Relationship(
back_populates="job_posts",

View File

@ -41,6 +41,7 @@ def serialize_job_post(row, *, names=None) -> dict:
"title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
"department_id": str(row.department_id) if row.department_id else None,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
@ -82,7 +83,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
return {
"id": str(row.id),
"title": row.title,
"department": row.department or None,
"department": row.department_ref.name if row.department_ref else None,
"department_id": str(row.department_id) if row.department_id else None,
"location": row.location,
"employment_type": row.employment_type,
"vacancies": row.vacancies,

View File

@ -87,6 +87,7 @@ class JobPostCreate(BaseModel):
location: str | None = None
employment_type: str | None = None
department: str | None = None
department_id: UUID | None = None
vacancies: int = 1
description: str | None = None
platform: str | None = None
@ -185,6 +186,7 @@ class JobPost:
"salary":payload.get("salary") or "Anonymous",
# department is NOT NULL with a server_default of "" — pass "", never None.
"department":payload.get("department") or "",
"department_id":None,
"vacancies":payload.get("vacancies") or 1,
"description":payload.get("description"),
"post_text":text,
@ -195,6 +197,10 @@ class JobPost:
# Only set platform when it is actually known: passing None would override the
# column default and break the NOT NULL constraint. Buffer's channelService
# replaces this with the authoritative value once the post is created.
if payload.get("department_id"):
department=await self._require_department(payload.get("department_id"))
fields["department_id"]=department.id
fields["department"]=department.name
known_platform=service or normalize_platform(payload.get("platform"),aliases)
if known_platform:
fields["platform"]=known_platform
@ -474,6 +480,13 @@ class JobPost:
hiring_manager_name=names.get(str(row.hiring_manager_id)),
)
async def _require_department(self,department_id):
from department.models import Department
department=await Department.get_by_id(self.session,department_id)
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return department
async def update_job(self,job_post_id,payload,current_user):
if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated")
@ -494,6 +507,16 @@ class JobPost:
fields["salary"]=str(high)
if "department" in fields and fields["department"] is None:
fields["department"]=""
if "department_id" in payload:
if payload.get("department_id"):
department=await self._require_department(payload.get("department_id"))
fields["department_id"]=department.id
fields["department_ref"]=department
fields["department"]=department.name
else:
fields["department_id"]=None
fields["department_ref"]=None
fields["department"]=""
assignment=Assignment(self.session)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None

View File

@ -24,6 +24,7 @@ from talent.app import router as talent_router
from candidate_forms.app import router as candidate_forms_router
from g_sheet.app import router as g_sheet_router
from s3.app import router as s3_router
from department.app import router as department_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -136,3 +137,4 @@ app.include_router(talent_router)
app.include_router(candidate_forms_router)
app.include_router(g_sheet_router)
app.include_router(s3_router)
app.include_router(department_router)

View File

@ -0,0 +1,15 @@
-- 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

@ -0,0 +1,92 @@
-- 038_departments.sql
-- Departments as a managed entity (backend/department/models.py): name, short
-- code, subtitle (replaces the design's "Cost Center"), description, status,
-- head, parent department and region/location list. Plus the `department`
-- permission module (8 tags), a `department_management` bundle holding them,
-- and that bundle attached to the admin roles.
--
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
-- and recorded in manual_migrations. Needed because prod boots with
-- DB_AUTOGENERATE=false and never autogenerates new tables. Index names match
-- the db_setup NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
-- Users must log in again afterwards — the frontend caches /users/me permissions.
-- =============================================================================
-- 1. Table
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.departments (
id uuid PRIMARY KEY,
name varchar NOT NULL,
short_code varchar(10) NOT NULL,
subtitle varchar,
description varchar,
is_active boolean NOT NULL DEFAULT true,
parent_department_id uuid REFERENCES app.departments(id),
department_head_id uuid REFERENCES app.users(id),
location jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
created_by uuid REFERENCES app.users(id),
updated_by uuid REFERENCES app.users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_name
ON app.departments (name);
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_short_code
ON app.departments (short_code);
CREATE INDEX IF NOT EXISTS ix_departments_parent_department_id
ON app.departments (parent_department_id);
-- =============================================================================
-- 2. The 8 department.* permission tags
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
VALUES
('department.view', 'department', 'view', NULL, NOW(), NOW(), true, false),
('department.create', 'department', 'create', NULL, NOW(), NOW(), true, false),
('department.edit', 'department', 'edit', NULL, NOW(), NOW(), true, false),
('department.delete', 'department', 'delete', NULL, NOW(), NOW(), true, false),
('department.approve', 'department', 'approve', NULL, NOW(), NOW(), true, false),
('department.export', 'department', 'export', NULL, NOW(), NOW(), true, false),
('department.manage', 'department', 'manage', NULL, NOW(), NOW(), true, false),
('department.configure', 'department', 'configure', NULL, NOW(), NOW(), true, false)
ON CONFLICT (tag_name) DO NOTHING;
-- =============================================================================
-- 3. Bundle holding all eight department tags
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'department_management',
'Departments: view, create, edit and manage departments',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND module = 'department'
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'department_management'
);
-- =============================================================================
-- 4. Attach the bundle to the admin roles (idempotent)
-- =============================================================================
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'department_management'
AND r.role_name IN (
'system_administrator',
'hr_administrator'
)
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));

View File

@ -0,0 +1,50 @@
-- 039_requisition_department_id.sql
-- Many requisitions -> one department. Adds requisitions.department_id, the FK
-- behind Requisition.department_id / Requisition.department and
-- Department.requisitions (backend/candidate_forms/models.py,
-- backend/department/models.py).
--
-- A new file, not an edit to 020 or 038: manual migrations run once and are
-- recorded in manual_migrations, so changes to an applied file never reach an
-- existing database. Applied at startup by alembic_setup.run_manual_sql()
-- after 038, so app.departments already exists.
--
-- The legacy free-text requisitions.department column is kept and only read
-- here to backfill: rows whose text equals a department's name or short code
-- (case-insensitive, trimmed) get that department's id. Unmatched rows stay
-- NULL. Drop the text column in a later migration once nothing reads it.
ALTER TABLE app.requisitions
ADD COLUMN IF NOT EXISTS department_id uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_requisitions_department_id_departments'
) THEN
ALTER TABLE app.requisitions
ADD CONSTRAINT fk_requisitions_department_id_departments
FOREIGN KEY (department_id) REFERENCES app.departments (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ix_requisitions_department_id
ON app.requisitions (department_id);
-- Backfill from the legacy text column, if it is still there.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'requisitions'
AND column_name = 'department'
) THEN
UPDATE app.requisitions r
SET department_id = d.id
FROM app.departments d
WHERE r.department_id IS NULL
AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code));
END IF;
END $$;

View File

@ -0,0 +1,63 @@
-- 040_job_post_department_id.sql
-- Move the department link from requisitions to job posts: many job posts -> one
-- department. Reverses 039 (requisitions.department_id) and adds
-- job_posts.department_id, the FK behind JobPosts.department_id /
-- JobPosts.department_ref and Department.job_posts.
--
-- job_posts.department (free text) stays: analytics, filters and the talent pool
-- key off it, and the app now writes the department's name there whenever
-- department_id is set.
--
-- Idempotent; applied at startup by alembic_setup.run_manual_sql() after 039.
-- =============================================================================
-- 1. requisitions: drop the 039 link, keeping the department name as text
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app' AND table_name = 'requisitions' AND column_name = 'department_id'
) THEN
-- Rows created while the link existed wrote only department_id; carry the name back.
UPDATE app.requisitions r
SET department = d.name
FROM app.departments d
WHERE r.department_id = d.id
AND (r.department IS NULL OR btrim(r.department) = '');
ALTER TABLE app.requisitions
DROP CONSTRAINT IF EXISTS fk_requisitions_department_id_departments;
DROP INDEX IF EXISTS app.ix_requisitions_department_id;
ALTER TABLE app.requisitions DROP COLUMN department_id;
END IF;
END $$;
-- =============================================================================
-- 2. job_posts.department_id -> departments.id
-- =============================================================================
ALTER TABLE app.job_posts
ADD COLUMN IF NOT EXISTS department_id uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_job_posts_department_id_departments'
) THEN
ALTER TABLE app.job_posts
ADD CONSTRAINT fk_job_posts_department_id_departments
FOREIGN KEY (department_id) REFERENCES app.departments (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ix_job_posts_department_id
ON app.job_posts (department_id);
-- Backfill: job posts whose text department equals a department's name or short
-- code (case-insensitive, trimmed). Unmatched rows stay NULL.
UPDATE app.job_posts j
SET department_id = d.id
FROM app.departments d
WHERE j.department_id IS NULL
AND lower(btrim(j.department)) IN (lower(d.name), lower(d.short_code));

View File

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

View File

@ -31,11 +31,6 @@ class Users(SQLModel, table=True):
role: Roles | None = Relationship(back_populates="users",
sa_relationship_kwargs={"lazy": "selectin"}
)
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts also has current_recruiter_id
# and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},

View File

@ -30,6 +30,7 @@ class PermissionModule(str, Enum):
JOBS = "jobs"
CANDIDATES = "candidates"
PIPELINE = "pipeline"
DEPARTMENT = "department"
INTERVIEWS = "interviews"
ASSESSMENTS = "assessments"
OFFERS = "offers"
@ -71,6 +72,16 @@ class PermissionTag(str, Enum):
DASHBOARD_EXPORT = "dashboard.export"
DASHBOARD_MANAGE = "dashboard.manage"
DASHBOARD_CONFIGURE = "dashboard.configure"
DEPARTMENT_VIEW = "department.view"
DEPARTMENT_CREATE = "department.create"
DEPARTMENT_EDIT = "department.edit"
DEPARTMENT_DELETE = "department.delete"
DEPARTMENT_APPROVE = "department.approve"
DEPARTMENT_EXPORT = "department.export"
DEPARTMENT_MANAGE = "department.manage"
DEPARTMENT_CONFIGURE = "department.configure"
INBOX_VIEW = "inbox.view"
INBOX_CREATE = "inbox.create"
INBOX_EDIT = "inbox.edit"

View File

@ -0,0 +1,42 @@
/**
* 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

@ -0,0 +1,198 @@
/* Candidate workspace integration + responsive checks. Start Vite first, then:
node candidate-profile.test.mjs
ATS_BASE_URL / CHROME_PATH override the local server/browser. All API calls
are intercepted with fixtures; this test never writes to the real backend.
ATS_SCREENSHOT_DIR optionally saves desktop and mobile preview images. */
import assert from 'node:assert/strict'
import { existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import puppeteer from 'puppeteer-core'
const base = process.env.ATS_BASE_URL || 'http://127.0.0.1:5173'
const chrome = process.env.CHROME_PATH || [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome', '/usr/bin/chromium',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
].find(existsSync)
assert(chrome, 'Set CHROME_PATH to an installed Chrome browser')
const permissions = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'settings', 'requisitions'].flatMap((module) => ['view', 'create', 'edit', 'manage'].map((action) => `${module}.${action}`))
const user = { id: 'test-recruiter', name: 'Adeel Haider', email: 'adeel@example.com', role_name: 'hr_administrator', permissions }
const fixture = {
user_id: 'test-candidate', inbox_id: 101, message_id: 'test-message', name: 'Sarah Khan',
email: 'sarah.khan@example.com', phone: '+92 300 0000000', city: 'Lahore, Pakistan',
currentCompany: 'Techvista Solutions', current_title: 'Digital Marketing Specialist',
experience: '5 years', education: 'BBA - Marketing, LUMS', linkedin_url: 'https://example.com/sarah',
job_title: 'Marketing Manager', assigned_job_post_id: 'job-marketing', source: 'LinkedIn',
application_status: 'PENDING', applied: '2026-09-09T09:00:00Z', rating: 4, favorite: false,
professional_summary: 'Results-driven digital marketing professional with 5 years of experience in developing and executing data-driven marketing strategies. Experienced in increasing brand visibility, improving customer engagement, and delivering measurable growth through SEO, PPC, and social media campaigns.',
matched_keywords: ['Digital Marketing', 'SEO', 'Google Ads', 'Social Media', 'Content Marketing', 'Analytics', 'Brand Strategy'],
recruiter: 'Adeel Haider', documents: [
{ name: 'Sarah_Khan_Resume.pdf', path: 'Email/test/resume.pdf' },
{ name: 'Portfolio.pdf', path: 'Email/test/portfolio.pdf' },
{ name: 'Cover_Letter.pdf', path: 'Email/test/cover.pdf' },
],
previous_applications: [
{ source: 'inbox', inbox_id: 101, message_id: 'test-message', job_post_id: 'job-marketing', job_title: 'Marketing Manager', status: 'PENDING', applied_at: '2026-09-09' },
{ source: 'inbox', inbox_id: 99, message_id: 'old-message', job_post_id: 'job-social', job_title: 'Social Media Specialist', status: 'CLOSED', applied_at: '2026-08-03' },
{ source: 'manual', manual_upload_candidate_id: 'manual-old', job_title: 'Digital Marketing Executive', status: 'REJECTED', applied_at: '2026-07-20' },
],
notes: [{ id: 'note-1', note: 'Relevant paid-media experience. Explore budget ownership during screening.', created_by_name: 'Adeel Haider', created_at: '2026-09-09T11:30:00Z' }],
interviews: [{ id: 'interview-1', interview_type: 'Phone Screen', interview_date: '2026-09-11T09:00:00Z', interview_status: 'Scheduled' }],
activity: [{ id: 'activity-1', activity_type: 'Recruiter assigned', description: 'Adeel Haider is responsible for the current application.', activity_date: '2026-09-10T09:00:00Z' }],
}
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'], defaultViewport: { width: 1536, height: 1100 } })
const page = await browser.newPage()
const errors = []
const writes = []
const reads = []
let candidate = structuredClone(fixture)
let activeUser = user
let failDetail = false
page.on('pageerror', (error) => errors.push(error.message))
await page.setRequestInterception(true)
page.on('request', async (request) => {
if (!['fetch', 'xhr', 'preflight'].includes(request.resourceType()) && new URL(request.url()).port !== '8000') return request.continue()
const path = new URL(request.url()).pathname
reads.push({ path, url: request.url() })
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 (path === '/users/me') data = activeUser
if (path === '/candidate/fetch') {
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 === '/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 (request.method() !== 'GET') {
const body = JSON.parse(request.postData() || '{}')
writes.push({ path, body })
if (path === '/candidate/update') Object.assign(candidate, body)
if (path === '/candidate/stage') candidate.application_status = body.to_stage
if (path === '/notes/create') candidate.notes.push({ id: 'new-note', note: body.note, created_at: new Date().toISOString() })
}
return request.respond({ status: path === '/candidate/fetch' && failDetail ? 500 : 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify({ data, status_code: 200 }) })
})
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 }))
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)
async function clickText(selector, text) {
const clicked = await page.evaluate((selector, text) => {
const button = [...document.querySelectorAll(selector)].find((item) => item.textContent.trim() === text)
if (!button) return false
button.click(); return true
}, selector, text)
assert(clicked, `Missing ${text}`)
}
async function load() {
await page.goto(`${base}/candidate/test-candidate`, { waitUntil: 'networkidle0' })
await page.waitForSelector('.cw-hero h1')
}
try {
await load()
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.ok(await page.$('.sidebar'), 'Candidate page keeps the app sidebar')
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')
for (const width of [1536, 1280, 1024, 768, 390, 320]) {
await page.setViewport({ width, height: 1100 })
const overflow = await page.evaluate(() => [document.documentElement, document.querySelector('.content'), document.querySelector('.cand-page')].map((node) => node.scrollWidth - node.clientWidth))
assert(overflow.every((amount) => amount <= 1), `Overflow at ${width}px: ${overflow}`)
if (process.env.ATS_SCREENSHOT_DIR && [1536, 390].includes(width)) {
mkdirSync(process.env.ATS_SCREENSHOT_DIR, { recursive: true })
await page.screenshot({ path: join(process.env.ATS_SCREENSHOT_DIR, `candidate-${width}.png`), fullPage: true })
}
}
console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)')
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 page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
await page.click('.cw-rating [role=radio]:nth-child(5)')
await page.waitForFunction(() => document.querySelector('.cw-rating').textContent.includes('5.0 / 5'))
assert(writes.some((write) => write.path === '/candidate/update' && write.body.rating === 5))
await clickText('.cw-action-grid button', 'Add Note')
await page.type('.candidate-dialog textarea', 'Follow up on campaign results.')
await clickText('.candidate-dialog button', 'Add Note')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/notes/create' && write.body.note === 'Follow up on campaign results.'))
console.log('ok Favorite, rating and notes retain existing API requests')
await clickText('.cw-action-grid button', 'Schedule Interview')
await page.$eval('.candidate-dialog input[type=date]', (input) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
setter.call(input, '2027-01-20'); input.dispatchEvent(new Event('input', { bubbles: true }))
})
await clickText('.candidate-dialog button', 'Schedule Interview')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/interview/create' && write.body.inbox_id === 101))
const downloadSession = await page.createCDPSession()
await downloadSession.send('Page.setDownloadBehavior', { behavior: 'deny' })
await clickText('.cw-hero-actions button', 'Download CV')
await page.waitForFunction(() => !document.querySelector('.cw-hero-actions button').disabled)
assert(reads.some((read) => read.path === '/s3/open' && new URL(read.url).searchParams.get('key') === 'Email/test/resume.pdf'))
assert(reads.some((read) => read.path === '/fixture-resume.pdf'))
assert(!reads.some((read) => read.path === '/documents/download'), 'S3 documents must not use the local-file download endpoint')
console.log('ok Interview scheduling and signed resume download')
await page.select('.cw-status-grid select', 'Interview')
const beforeCancel = writes.length
await clickText('.candidate-dialog button', 'Cancel')
assert.equal(writes.length, beforeCancel, 'Cancelling must not update stage')
await page.select('.cw-status-grid select', 'Interview')
await clickText('.candidate-dialog button', 'Save Stage')
await page.waitForSelector('.candidate-dialog', { hidden: true })
assert(writes.some((write) => write.path === '/candidate/stage' && write.body.to_stage === 'INTERVIEW'))
await clickText('.cw-action-grid button', 'Reject')
assert(await page.$eval('.candidate-dialog button[type=submit]', (button) => button.disabled), 'Rejection needs a reason')
await clickText('.candidate-dialog button', 'Cancel')
console.log('ok Stage confirmation, cancellation, rejection reason')
for (const tab of ['Resume', 'Interviews', 'Forms', 'Notes', 'Activity', 'Timeline', 'History', 'Overview']) {
await page.evaluate((label) => [...document.querySelectorAll('[role=tab]')].find((item) => item.textContent.startsWith(label)).click(), tab)
await page.waitForFunction((label) => document.querySelector('[role=tab][aria-selected=true]')?.textContent.startsWith(label), {}, tab)
}
await page.click('[role=tab][aria-selected=true]')
await page.keyboard.press('ArrowRight')
assert((await page.$eval('[role=tab][aria-selected=true]', (tab) => tab.textContent)).startsWith('Resume'))
assert(await page.$eval('[role=tabpanel]', (panel) => Boolean(document.getElementById(panel.getAttribute('aria-labelledby')))))
console.log('ok All tabs and keyboard navigation')
activeUser = { ...user, role_name: 'hiring_manager' }
await load()
assert.deepEqual(await page.$$eval('[role=tab]', (tabs) => tabs.map((tab) => tab.textContent.replace(/\d/g, '').trim())), ['Forms', 'Notes'])
assert.equal(await page.$('.cw-overview'), null)
console.log('ok Hiring manager restricted view')
activeUser = { ...user, permissions: ['candidates.view', 'interviews.view'] }
candidate = { user_id: 'test-candidate', name: 'Candidate with no attachments', notes: [], interviews: [], documents: [] }
await load()
assert.equal(await page.$('.cw-document'), null)
assert(await page.$eval('.cand-page-actions button', (button) => button.disabled))
assert(await page.$eval('.cw-status-grid select', (select) => select.disabled))
assert((await page.$eval('.cw-overview', (element) => element.textContent)).includes('No resume attached'))
console.log('ok Missing fields, missing attachments and read-only permissions')
activeUser = user
failDetail = true
await load()
await page.waitForFunction(() => document.querySelector('.cand-page').textContent.includes('Could not load this candidate'), { timeout: 15000 })
assert.equal(await page.$('.cw-overview'), null, 'Do not show stale candidate details after a failed load')
failDetail = false
await clickText('.cand-page button', 'Try again')
await page.waitForSelector('.cw-overview')
console.log('ok Load failure and retry')
assert.deepEqual(errors, [], 'No runtime errors')
console.log('All candidate workspace checks passed')
} finally { await browser.close() }

View File

@ -8,6 +8,13 @@ server {
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
client_max_body_size 25m;
# Resolve backend-api through Docker's embedded DNS on each request instead of
# once at startup. `docker compose up` recreates backend-api with a new IP;
# a static upstream keeps the old one and every API call 502s until nginx restarts.
# proxy_pass with a variable and no URI forwards the original request URI unchanged.
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_api http://backend-api:8000;
# Security headers on every response.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
@ -23,7 +30,7 @@ server {
# SPA page roots that also prefix API calls sub-path required.
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
proxy_pass http://backend-api:8000;
proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -37,8 +44,8 @@ server {
}
# API-only prefixes (no SPA page at the bare path).
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
proxy_pass http://backend-api:8000;
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) {
proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -14,10 +14,12 @@
"test:format": "node format.test.mjs",
"test:inbox": "node inbox-loading.test.mjs",
"test:candidates": "node candidates-table.test.mjs",
"test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs",
"test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.test.mjs",
"test:jobprofile": "node job-profile.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 job-profile.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 job-profile.test.mjs && node candidate-browse.test.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",

View File

@ -1,5 +1,5 @@
import { lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom'
import AuthProvider from './auth/AuthProvider'
import RequireAuth from './auth/RequireAuth'
@ -34,6 +34,7 @@ const SCREENS = {
assessments: lazy(() => import('./screens/Assessments')),
offers: lazy(() => import('./screens/Offers')),
managers: lazy(() => import('./screens/Managers')),
departments: lazy(() => import('./screens/Departments')),
calendar: lazy(() => import('./screens/Calendar')),
reports: lazy(() => import('./screens/Reports')),
analytics: lazy(() => import('./screens/Analytics')),
@ -46,6 +47,14 @@ const SCREENS = {
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
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 />
}
const JobProfile = lazy(() => import('./screens/JobProfile'))
export default function App() {
@ -104,6 +113,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/candidates/:userId"
element={
<RequireAuth permission="candidates.view">
<LegacyCandidateRedirect />
</RequireAuth>
}
/>
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -0,0 +1,51 @@
import { request } from '../lib/apiClient'
/* ============================================================
departments.js backend/department/app.py routes.
created_by / updated_by come from the JWT on the server do not send them.
`location` is a string[]; the form edits it as comma-separated text.
============================================================ */
export function list({ search, isActive, top, skip } = {}) {
return request('/department/fetch', {
params: { search: search || undefined, is_active: isActive, top, skip },
})
}
export function getById(recordId) {
return request('/department/fetch', { params: { record_id: recordId } })
}
export function create(body) {
return request('/department/create', { method: 'POST', body })
}
export function update(recordId, body) {
return request('/department/update', { method: 'PUT', params: { record_id: recordId }, body })
}
/** Department Head picker `{data:[{id,name,email}]}`. The server defaults
* `role_id` to the department_head role when it is not sent. */
export function listHeads({ roleId, search, top, skip } = {}) {
return request('/department/heads/fetch', {
params: { role_id: roleId, search: search || undefined, top, skip },
})
}
/** Region / Location picker — `{data:["Karachi - Pakistan", …]}` from global_cities.py. */
export function listLocations({ search } = {}) {
return request('/department/locations/fetch', { params: { search: search || undefined } })
}
/** Active departments for pickers (Requisitions "From (Dept.)") — `{data:[{id,name}]}`. */
export function listNames({ search } = {}) {
return request('/department/names', { params: { search: search || undefined } })
}
export function toRows(res) {
const data = res?.data
if (Array.isArray(data)) return data
if (data) return [data]
return []
}

View File

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

View File

@ -90,6 +90,7 @@ export function toJobView(row) {
id: row.id,
title: row.title,
department: row.department,
departmentId: row.department_id || null,
location: row.location,
type: row.employment_type,
vacancies: row.vacancies,

View File

@ -22,6 +22,11 @@ export function list() {
return request('/forms/requisition/fetch')
}
/** GET /forms/requisition/open-count — `{data:{open}}`: unlinked, or linked job still open. */
export function countOpen() {
return request('/forms/requisition/open-count')
}
export function getById(formId) {
return request('/forms/requisition/fetch', { params: { form_id: formId } })
}

View File

@ -12,9 +12,17 @@ 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. */
export function firstKey(filePath) {
return (filePath || '').split(',')[0].trim() || null
if (Array.isArray(filePath)) return firstKey(filePath[0])
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/... */
@ -37,9 +45,17 @@ export function canOpen(filePath) {
/** First usable S3/http ref on a candidate or inbox payload. */
export function resumeKeyFrom(item) {
if (!item) return null
const fromFiles = (item.files || []).map((f) => f.url).find(Boolean)
if (typeof item.files === 'string') {
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)
const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean)
if (typeof item.documents === 'string') {
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)
return firstKey(item.file_path || item.filePath)
}

View File

@ -20,6 +20,7 @@ export default function AppLayout() {
const badges = useBadges()
const routeKey = location.pathname.split('/')[1] || 'dashboard'
const candidateView = routeKey === 'candidate'
const route = ROUTE_BY_PATH[routeKey]
useRouteMeta(route)
@ -38,7 +39,7 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen])
return (
<div id="app">
<div id="app" className={candidateView ? 'candidate-workspace' : undefined}>
<a className="skip-link" href="#main-content">Skip to content</a>
<Sidebar
collapsed={collapsed}

View File

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

View File

@ -44,6 +44,7 @@ export const ROUTES = [
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
{ path: 'departments', title: 'Departments', icon: 'layers', group: 'Hiring', permission: 'department.view', tag: 'NEW' },
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
// --- Insights ---

View File

@ -16,7 +16,7 @@
export const MODULES = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
'talent', 'requisitions',
'talent', 'requisitions', 'department',
]
export const ACTIONS = [

View File

@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component {
<EmptyState icon="alert" title="Something went wrong">
This screen hit an unexpected error. The rest of the app is fine
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>
<div style={{ textAlign: 'center' }}>
<button className="btn btn-secondary" onClick={() => this.setState({ error: null })}>

View File

@ -0,0 +1,64 @@
/* 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

@ -108,6 +108,7 @@ export const qk = {
applications: (email) => ['candidates', 'applications', email],
matching: (p = {}) => ['candidates', 'matching', p],
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
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
@ -143,9 +144,17 @@ export const qk = {
requisitions: {
all: () => ['requisitions'],
list: () => ['requisitions', 'list'],
openCount: () => ['requisitions', 'open-count'],
detail: (id) => ['requisitions', 'detail', id],
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
},
departments: {
all: () => ['departments'],
list: (p = {}) => ['departments', 'list', p],
heads: (p = {}) => ['departments', 'heads', p],
locations: () => ['departments', 'locations'],
names: (q = '') => ['departments', 'names', q],
},
interviews: {
all: () => ['interviews'],
range: (p = {}) => ['interviews', 'range', p],

View File

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

View File

@ -4,20 +4,87 @@
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
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.
import { useNavigate, useParams } from 'react-router-dom'
Previous / Next walk the session queue written when the recruiter opened
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'
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() {
const { userId } = useParams()
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 (
<CandidateProfile
key={userId}
variant="page"
candidate={{ id: userId, userId, name: '' }}
browse={browse}
onBrowse={goTo}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
/>
)

View File

@ -1,5 +1,5 @@

import { useMemo, useState } from 'react'
import { useId, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -19,6 +19,8 @@ import * as formsApi from '../api/forms'
import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms'
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
import { CandidateBrowseNav } from './CandidateBrowse'
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed'
@ -108,18 +110,31 @@ function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
*/
export default function CandidateProfile({
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
variant = 'modal',
variant = 'modal', browse = null, onBrowse,
}) {
const { toast } = useToast()
const { can, user } = useAuth()
const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const [tab, setTab] = useState(() => tabFromSearch(
variant === 'page' ? searchParams.get('tab') : null,
visibleTabs,
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 [dialog, setDialog] = useState(null)
const [stageReason, setStageReason] = useState('')
const openAction = (type, stage) => { setStageReason(''); setDialog({ type, stage }) }
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const isLive = Boolean(c.userId)
@ -130,7 +145,7 @@ export default function CandidateProfile({
// employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
const candidateInterviews = (Array.isArray(interviews) ? interviews : []).filter((i) => i.candidateId === c.id)
// The application row interviews/activity/feedback attach to. Detail mode
// flattens every application the candidate owns; writes land on the first,
@ -153,7 +168,7 @@ export default function CandidateProfile({
const setFavorite = useProfileWrite({
userId: c.userId,
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
success: (next) => (next ? `${live?.name || c.name || 'Candidate'} added to favorites` : 'Removed from favorites'),
})
// Same PATCH as favorite: the server writes rating onto every inbox row the
@ -215,6 +230,23 @@ export default function CandidateProfile({
},
})
const moveStage = useProfileWrite({
userId: c.userId,
mutationFn: () => pipelineApi.changeStage({
inboxId: live?.inbox_id ?? undefined,
manualUploadId: live?.inbox_id ? undefined : live?.manual_upload_candidate_id,
toStage: pipelineApi.STATUS_FROM_STAGE[dialog.stage],
changeReason: stageReason.trim() || 'Updated from candidate profile',
}),
success: () => `Moved to ${dialog.stage}`,
onDone: () => {
setDialog(null)
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.forms.all() })
qc.invalidateQueries({ queryKey: qk.analytics.all() })
},
})
// The hero experience chip: live experience is free text ("6 years"), seed is
// a number. Render nothing rather than a bare "yrs exp".
const expRaw = live?.experience ?? c.experience
@ -224,7 +256,7 @@ export default function CandidateProfile({
const counts = live && {
Interview: live.interviews?.length ?? 0,
Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length,
Forms: (Array.isArray(formsQuery.data?.data) ? formsQuery.data.data : []).filter((r) => r.form_type !== 'requisition').length,
Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0,
@ -239,6 +271,7 @@ export default function CandidateProfile({
) : detail.isError ? (
<EmptyState icon="alert" title="Could not load this candidate">
{friendlyAuthError(detail.error, 'Please try again.')}
<button className="btn btn-secondary" onClick={() => detail.refetch()}>Try again</button>
</EmptyState>
) : !live ? (
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
@ -294,7 +327,7 @@ export default function CandidateProfile({
const body = (
<>
<div className="profile-hero">
{variant === 'page' ? <CandidateWorkspaceHero candidate={live || c} stage={live ? stageLabel : null} restricted={isManager} /> : <div className="profile-hero">
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">
@ -339,19 +372,25 @@ export default function CandidateProfile({
{live?.professional_summary ? <div className="ats-summary">{live.professional_summary}</div> : null}
</div>
) : null}
</div>
</div>}
<div style={{ marginTop: 22 }}>
<div className={variant === 'page' ? 'cw-tabs' : undefined} style={variant === 'page' ? undefined : { marginTop: 22 }}>
<Tabs
idBase={tabId}
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
onChange={changeTab}
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 }))}
/>
</div>
<div className="tab-pane active">
{tab === 'Overview' && (guard || (live ? (
<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
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction}
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
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}
/> : live ? (
<>
<PreviousApplications row={live} />
<div className="info-grid" style={{ marginBottom: 20 }}>
@ -406,11 +445,11 @@ export default function CandidateProfile({
</>
)}
{live.job_posts?.length > 0 && (
{Array.isArray(live.job_posts) && live.job_posts.length > 0 && (
<>
<div style={LABEL}>Suggested Roles</div>
<div className="k-tags">
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
{live.job_posts.filter(Boolean).map((j) => <span className="tag" key={j.id || j.title}>{j.title}</span>)}
</div>
</>
)}
@ -432,7 +471,7 @@ export default function CandidateProfile({
<Info label="Rating" val={`${c.rating} / 5.0`} />
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
<div className="k-tags">{(Array.isArray(c.skills) ? c.skills : []).map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</>
)))}
@ -623,17 +662,34 @@ export default function CandidateProfile({
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong>
</div>
<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>
{actions && <div className="cand-page-actions">{actions}</div>}
</div>
<div className="card">
<div className="card-body">{body}</div>
</div>
{body}
{dialog && live && <Modal
title={{ interview: 'Schedule Interview', note: 'Add Note', stage: dialog.stage === 'Rejected' ? 'Reject Application' : 'Move to Stage', share: 'Share Profile' }[dialog.type]}
subtitle={live.name}
size="candidate-dialog"
onClose={() => { if (!moveStage.isPending) setDialog(null) }}
>
{dialog.type === 'interview' && <InterviewTab userId={c.userId} inboxId={inboxId} rows={[]} onSaved={() => setDialog(null)} />}
{dialog.type === 'note' && <NotesTab userId={c.userId} rows={[]} onSaved={() => setDialog(null)} />}
{dialog.type === 'share' && <div className="form-field"><label htmlFor="candidate-share-link">Profile link</label><input id="candidate-share-link" readOnly value={window.location.href} onFocus={(event) => event.target.select()} /><p className="text-muted">Copy this link to share with a member of your hiring team.</p></div>}
{dialog.type === 'stage' && <form onSubmit={(event) => { event.preventDefault(); if (!moveStage.isPending) moveStage.mutate() }}>
<p className="cw-summary">{live.job_title || 'Current application'} · Currently {stageLabel}</p>
<div className="form-field"><label htmlFor="candidate-stage">Move to</label><select id="candidate-stage" value={dialog.stage} disabled={moveStage.isPending} onChange={(event) => setDialog({ ...dialog, stage: event.target.value })}>{Object.keys(pipelineApi.STATUS_FROM_STAGE).map((stage) => <option key={stage}>{stage}</option>)}</select></div>
<div className="form-field"><label htmlFor="candidate-stage-reason">Reason{['Rejected', 'On Hold'].includes(dialog.stage) ? ' (required)' : ' (optional)'}</label><textarea id="candidate-stage-reason" value={stageReason} disabled={moveStage.isPending} onChange={(event) => setStageReason(event.target.value)} required={['Rejected', 'On Hold'].includes(dialog.stage)} placeholder="Add context for the hiring team…" /></div>
<div className="cw-dialog-actions"><button type="button" className="btn btn-secondary" disabled={moveStage.isPending} onClick={() => setDialog(null)}>Cancel</button><button type="submit" className={`btn ${dialog.stage === 'Rejected' ? 'cw-danger' : 'btn-primary'}`} disabled={!can('pipeline.edit') || moveStage.isPending || dialog.stage === stageLabel || (['Rejected', 'On Hold'].includes(dialog.stage) && !stageReason.trim())}>{moveStage.isPending ? 'Saving…' : dialog.stage === 'Rejected' ? 'Reject Application' : 'Save Stage'}</button></div>
</form>}
</Modal>}
</div>
)
}
@ -906,8 +962,9 @@ function HistoryRow({ row: r }) {
)
}
function InterviewTab({ userId, inboxId, rows }) {
function InterviewTab({ userId, inboxId, rows, onSaved }) {
const { toast } = useToast()
const { can } = useAuth()
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
@ -919,7 +976,7 @@ function InterviewTab({ userId, inboxId, rows }) {
inboxId, date: instant, time: instant, type: form.type, status: form.status,
}),
success: 'Interview scheduled',
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
onDone: () => { setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }); onSaved?.() },
})
function submit() {
@ -986,7 +1043,7 @@ function InterviewTab({ userId, inboxId, rows }) {
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
disabled={!inboxId || create.isPending || !(can('interviews.create') || can('candidates.create'))}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
@ -1001,13 +1058,14 @@ function InterviewTab({ userId, inboxId, rows }) {
)
}
function NotesTab({ userId, rows }) {
function NotesTab({ userId, rows, onSaved }) {
const { can } = useAuth()
const [text, setText] = useState('')
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
success: 'Note saved',
onDone: () => setText(''),
onDone: () => { setText(''); onSaved?.() },
})
return (
@ -1023,7 +1081,7 @@ function NotesTab({ userId, rows }) {
<button
className="btn btn-primary btn-sm"
style={{ margin: '10px 0 18px' }}
disabled={!text.trim() || create.isPending}
disabled={!text.trim() || create.isPending || !can('candidates.create')}
onClick={() => create.mutate()}
>
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}

View File

@ -0,0 +1,257 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { Avatar, Badge, Icon, ScoreChip, Stars } from '../ui/primitives'
import OpenResumeButton from '../ui/OpenResumeButton'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { applicationStatusLabel, candidateApplicationsOf, hrefForPreviousApplication, ReappliedBadge } from '../components/ReapplicantHistory'
import { fmtDate, toDate } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3'
import { STATUS_FROM_STAGE } from '../api/pipeline'
import '../styles/candidate-workspace.css'
const display = (value) => value === 0 ? '0' : 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 appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle
function asList(value) {
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) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null }
}
export function WorkspaceCard({ title, action, children, className = '' }) {
return <section className={`cw-card ${className}`}>
<div className="cw-card-head"><h2>{title}</h2>{action}</div>
{children}
</section>
}
function ViewAll({ onClick, expanded }) {
return <button className="cw-link" onClick={onClick}><Icon name="arrow-right" />{expanded ? 'Show less' : 'View all'}</button>
}
function useDownload(candidate) {
const { toast } = useToast()
return useMutation({
mutationFn: async ({ index = 0, filename } = {}) => {
const path = candidate.documents?.[index]?.path
// The legacy document route serves local files only. S3 attachments use
// the same presign endpoint as Open Resume, without forwarding auth to S3.
if (s3Api.canOpen(path)) {
const key = s3Api.firstKey(path)
const url = s3Api.isS3Ref(key) ? (await s3Api.openUrl(key))?.data?.url : key
if (!url) throw new Error('Could not open this document.')
let response
try { response = await fetch(url) }
catch { throw new Error('Open the document and use the viewers download button.') }
if (!response.ok) throw new Error('Could not download this document. Please try again.')
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = filename || 'Candidate document'
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000)
return
}
return candidatesApi.downloadDocument({
inboxId: candidate.inbox_id,
manualUploadCandidateId: candidate.inbox_id ? undefined : candidate.manual_upload_candidate_id,
index, filename,
})
},
onError: (error) => toast(friendlyAuthError(error, 'Could not download the document.'), 'error'),
})
}
export function CandidateWorkspaceHero({ candidate, stage, restricted = false }) {
const resumeKey = s3Api.resumeKeyFrom(candidate)
const download = useDownload(candidate)
const resume = candidate.documents?.[0]
const linkedin = externalUrl(candidate.linkedin_url)
const facts = [
['briefcase', 'Applied for', appliedRole(candidate)],
['calendar', 'Applied on', fmtDate(candidate.applied)],
['send', 'Source', candidate.source],
['briefcase', 'Current company', candidate.currentCompany],
['clock', 'Experience', experience(candidate.experience)],
['award', 'Education', candidate.education],
['file', 'Total applications', candidateApplicationsOf(candidate).length],
]
return <header className="cw-hero">
<Avatar name={candidate.name} className="cw-avatar" color="linear-gradient(145deg, #b2acff, #9395f0)" />
<div className="cw-hero-body">
<div className="cw-hero-top">
<div className="cw-identity">
<div className="cw-name"><h1>{candidate.name || 'Candidate profile'}</h1>{stage && <Badge>{stage}</Badge>}<ReappliedBadge row={candidate} /></div>
<div className="cw-contact">
{candidate.email && <a href={`mailto:${candidate.email}`}><Icon name="mail" />{candidate.email}</a>}
{candidate.phone && <a href={`tel:${candidate.phone}`}><Icon name="phone" />{candidate.phone}</a>}
{profileLocation(candidate) && <span><Icon name="map" />{profileLocation(candidate)}</span>}
{linkedin && <a className="cw-external" href={linkedin} target="_blank" rel="noopener noreferrer"><Icon name="linkedin" />LinkedIn profile</a>}
</div>
</div>
{!restricted && <div className="cw-hero-actions">
{resume && (candidate.inbox_id || candidate.manual_upload_candidate_id) && <button className="btn btn-secondary" disabled={download.isPending} onClick={() => download.mutate({ filename: resume.name })}><Icon name="download" />{download.isPending ? 'Downloading…' : 'Download CV'}</button>}
<OpenResumeButton filePath={resumeKey} label="Open Resume" icon="eye" className="btn btn-primary" />
</div>}
</div>
{!restricted && <div className="cw-facts">{facts.map(([icon, label, value]) => <div className="cw-fact" key={label}>
<Icon name={icon} /><div><span>{label}</span><strong>{display(value)}</strong></div>
</div>)}</div>}
</div>
</header>
}
function CandidateInformation({ candidate }) {
const linkedin = externalUrl(candidate.linkedin_url)
const portfolio = externalUrl(candidate.portfolio_url || candidate.portfolio)
const rows = [
['user', 'Full name', candidate.name], ['mail', 'Email', candidate.email], ['phone', 'Phone', candidate.phone],
['map', 'Location', profileLocation(candidate)], ['briefcase', 'Current company', candidate.currentCompany],
['briefcase', 'Current title', candidate.current_title || candidate.currentTitle],
['clock', 'Experience', experience(candidate.experience)], ['award', 'Education', candidate.education],
['linkedin', 'LinkedIn', linkedin && <a href={linkedin} target="_blank" rel="noopener noreferrer">View LinkedIn profile</a>],
...(portfolio ? [['send', 'Portfolio', <a href={portfolio} target="_blank" rel="noopener noreferrer">View portfolio</a>]] : []),
...(candidate.notice_period ? [['calendar', 'Notice period', candidate.notice_period]] : []),
...(candidate.expected_salary ? [['dollar', 'Expected salary', candidate.expected_salary]] : []),
...(candidate.availability ? [['clock', 'Availability', candidate.availability]] : []),
]
return <WorkspaceCard title="Candidate Information"><dl className="cw-info">{rows.map(([icon, label, value]) => <div key={label}><dt><Icon name={icon} />{label}</dt><dd>{display(value)}</dd></div>)}</dl></WorkspaceCard>
}
function Applications({ candidate, stage }) {
const [expanded, setExpanded] = useState(false)
const rows = candidateApplicationsOf(candidate)
const visible = expanded ? rows : rows.slice(0, 3)
return <WorkspaceCard title={`Applications (${rows.length})`} action={rows.length > 3 && <ViewAll onClick={() => setExpanded(!expanded)} expanded={expanded} />}>
{rows.length ? <div className="cw-table-wrap"><table className="cw-applications">
<thead><tr><th>Job title</th><th>Applied on</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>{visible.map((application, index) => {
const current = Boolean(
(candidate.inbox_id && String(candidate.inbox_id) === String(application.inbox_id)) ||
(candidate.manual_upload_candidate_id && String(candidate.manual_upload_candidate_id) === String(application.manual_upload_candidate_id)),
)
const status = current ? stage : applicationStatusLabel(application.status, application)
const href = hrefForPreviousApplication(application)
return <tr key={`${application.inbox_id || application.manual_upload_candidate_id || application.form_data_id || 'application'}-${index}`} className={current ? 'is-current' : ''}>
<td><strong>{application.job_title || application.jobTitle || 'No job assigned'}</strong><small>{current ? 'Current application' : ({ inbox: 'Email application', manual: 'Manual application', form: 'Application form' }[application.source] || application.source || 'Application')}</small></td>
<td>{fmtDate(application.applied_at) || '—'}</td><td><Badge>{status}</Badge></td>
<td>{href ? <Link className="btn btn-secondary btn-sm" to={href} aria-label={`View ${application.job_title || 'application'}`}>View</Link> : <span className="cw-muted"></span>}</td>
</tr>
})}</tbody>
</table></div> : <p className="cw-empty">No applications recorded.</p>}
</WorkspaceCard>
}
function DocumentRow({ document, index, candidate, resume = false }) {
const download = useDownload(candidate)
const path = typeof document === 'string' ? document : document?.path
const name = (typeof document === 'string' ? document : document?.name) || 'Candidate document'
const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE'
return <div className="cw-document">
<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-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'} />
{(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>
}
function RecentActivity({ candidate, onTab }) {
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 })),
...asList(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.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 }] : []),
].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4)
return <WorkspaceCard title="Recent Activity" action={<ViewAll onClick={() => onTab('Timeline')} />}>
{events.length ? <ol className="cw-activity">{events.map((event, index) => <li key={index}>
<div className="cw-activity-top"><strong>{event.title}</strong><time>{fmtDate(event.date) || '—'}</time></div>
{event.detail && <p>{event.detail}</p>}{event.author && <small>By {event.author}</small>}
</li>)}</ol> : <p className="cw-empty">No activity recorded yet.</p>}
</WorkspaceCard>
}
export default function CandidateWorkspaceOverview({ candidate, stage, nextStage, onTab, onAction, rating, ratingPending, onRating, atsScore, recommendation, atsAction }) {
const { can } = useAuth()
const { toast } = useToast()
const skills = profileSkills(candidate)
const documents = asList(candidate.documents)
const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id)
const isClosed = ['Rejected', 'Hired'].includes(stage)
const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress'
const score = atsScore ?? candidate.ai_score
async function share() {
try { await navigator.clipboard.writeText(window.location.href); toast('Profile link copied', 'success') }
catch { onAction('share') }
}
return <div className="cw-overview">
<div className="cw-column cw-column-info">
<CandidateInformation candidate={candidate} />
<WorkspaceCard title="Skills & Tags">
{skills.length ? <div className="cw-skills">{skills.map((skill) => <span key={skill}>{skill}</span>)}</div> : <p className="cw-empty">No skills added to this profile.</p>}
</WorkspaceCard>
</div>
<div className="cw-column cw-column-main">
<Applications candidate={candidate} stage={stage} />
<WorkspaceCard title="Professional Summary"><p className="cw-summary">{candidate.professional_summary || 'No professional summary available yet.'}</p></WorkspaceCard>
<WorkspaceCard title="Resume">
{documents[0] ? <DocumentRow document={documents[0]} index={0} candidate={candidate} resume /> : s3Api.canOpen(s3Api.resumeKeyFrom(candidate)) ? <OpenResumeButton filePath={s3Api.resumeKeyFrom(candidate)} /> : <p className="cw-empty">No resume attached to this application.</p>}
</WorkspaceCard>
<div className="cw-bottom-grid">
<WorkspaceCard title={`Additional Documents (${Math.max(0, documents.length - 1)})`}>
{documents.length > 1 ? <div className="cw-document-list">{documents.slice(1).map((document, index) => <DocumentRow key={`${document.name}-${index}`} document={document} index={index + 1} candidate={candidate} />)}</div> : <p className="cw-empty">No additional documents attached.</p>}
</WorkspaceCard>
<div className="cw-column">
<WorkspaceCard title="Ratings">
<div className="cw-rating" role="radiogroup" aria-label="Candidate rating"><Stars value={Math.round(rating)} onChange={onRating} disabled={ratingPending || !can('candidates.edit')} /><span>{rating ? `${rating.toFixed(1)} / 5` : 'Not rated'}</span></div>
{ratingPending && <small role="status" className="cw-muted">Saving rating</small>}
</WorkspaceCard>
<WorkspaceCard title="Recruiter">
{candidate.recruiter ? <div className="cw-recruiter"><Avatar name={candidate.recruiter} color="linear-gradient(145deg, #b2acff, #9395f0)" /><div><strong>{candidate.recruiter}</strong><small>Hiring team</small></div></div> : <p className="cw-empty">No recruiter assigned.</p>}
</WorkspaceCard>
</div>
</div>
{(score != null || candidate.match_summary || candidate.match_reasoning || atsAction) && <WorkspaceCard title="AI Screening" action={atsAction}>
<div className="cw-screening">{score != null && <div className="cw-match"><ScoreChip score={score} /><small>{recommendation || candidate.recommendation || 'AI Match'}</small></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>
</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>}
</div>
<aside className="cw-column cw-column-actions" aria-label="Candidate actions and activity">
<WorkspaceCard title="Quick Actions"><div className="cw-action-grid">
<button className="btn btn-primary" disabled={!(can('interviews.create') || can('candidates.create')) || !candidate.inbox_id} onClick={() => onAction('interview')}><Icon name="calendar" />Schedule Interview</button>
<button className="btn btn-secondary" disabled={!canMove} onClick={() => onAction('stage', nextStage || stage)}><Icon name="arrow-right" />Move to Stage</button>
<button className="btn btn-secondary" disabled={!can('candidates.create')} onClick={() => onAction('note')}><Icon name="message" />Add Note</button>
{candidate.email ? <a className="btn btn-secondary" href={`mailto:${candidate.email}`}><Icon name="mail" />Send Email</a> : <button className="btn btn-secondary" disabled><Icon name="mail" />Send Email</button>}
<button className="btn btn-secondary" onClick={share}><Icon name="copy" />Share Profile</button>
<button className="btn btn-secondary" onClick={() => onTab('Forms')}><Icon name="file" />View Forms</button>
<button className="btn cw-danger" disabled={!canMove || stage === 'Rejected'} onClick={() => onAction('stage', 'Rejected')}><Icon name="x" />Reject</button>
</div></WorkspaceCard>
<WorkspaceCard title="Status & Stage"><p className="cw-active-application">Active application: {appliedRole(candidate) || 'No job assigned'}</p>
<div className="cw-status-grid"><div><span className="cw-field-label">Status</span><div className="cw-status-value"><span className={`cw-status-dot${isClosed ? ' is-closed' : ''}`} />{status}</div></div>
<label><span className="cw-field-label">Stage</span><select aria-label="Application stage" value={stage || ''} disabled={!canMove} onChange={(event) => onAction('stage', event.target.value)}>{!stage && <option value="">Not set</option>}{Object.keys(STATUS_FROM_STAGE).map((name) => <option key={name}>{name}</option>)}</select></label>
</div>
</WorkspaceCard>
<RecentActivity candidate={candidate} onTab={onTab} />
</aside>
</div>
}

View File

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

View File

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

View File

@ -0,0 +1,728 @@
/* ============================================================
Departments app.departments (backend/department/app.py).
List is GET /department/fetch, rendered as the card grid from the
"Departments — List" design. Create / edit share one modal from the
"Departments — Create" design, with the design's Cost Center field
replaced by Subtitle (the tagline under the name on each card).
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { exportStyledWorkbook } from '../lib/exportXlsx'
import * as departmentsApi from '../api/departments'
import * as requisitionsApi from '../api/requisitions'
// app.roles id of `department_head` the backend's default for /department/heads/fetch.
const DEPARTMENT_HEAD_ROLE_ID = 5
// Card icon tints cycle through the soft theme tokens so adjacent cards differ.
const TONES = ['success', 'primary', 'warning', 'purple', 'info']
function toneFor(id = '') {
let h = 0
for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** KPI numbers — shared by the card row and the export's Summary sheet. */
function departmentStats(rows) {
const sum = (key) => rows.reduce((n, r) => n + (Number(r[key]) || 0), 0)
const jobPosts = sum('job_posts')
const openRoles = sum('open_roles')
return {
total: rows.length,
candidates: sum('candidates'),
openRoles,
jobPosts,
// Avg. open job posts = all open job posts / all job posts linked to departments.
openShareLabel: jobPosts ? `${Math.round((openRoles / jobPosts) * 100)}%` : '—',
}
}
/** Sheet 1: the KPI cards. Sheet 2: one row per department with everything the page shows. */
function buildExportSheets(rows, stats, exportedAt) {
const subtitle = `Exported ${exportedAt.toLocaleString()}`
const when = (iso) => (iso ? new Date(iso).toLocaleString() : '')
return [
{
name: 'Summary',
title: 'Departments — Summary',
subtitle,
columns: [
{ header: 'Metric', key: 'metric', width: 30 },
{ header: 'Value', key: 'value', width: 16 },
{ header: 'Notes', key: 'notes', width: 52 },
],
rows: [
{ metric: 'Total Departments', value: stats.total, notes: 'Across the organization' },
{ metric: 'Candidates Applied', value: stats.candidates, notes: 'Applicants on jobs linked to departments' },
{ metric: 'Open Requisitions', value: stats.openRequisitions ?? '—', notes: 'Not linked to a job post, or linked to a job post that is still open' },
{ metric: 'Avg. Open Job Posts', value: stats.openShareLabel, notes: `${stats.openRoles} open of ${stats.jobPosts} job posts` },
],
},
{
name: 'Departments',
title: 'Departments',
subtitle,
columns: [
{ header: 'Department', key: 'name', width: 28 },
{ header: 'Short Code', key: 'short_code', width: 12 },
{ header: 'Subtitle', key: 'subtitle', width: 26 },
{ header: 'Description', key: 'description', width: 44 },
{ header: 'Status', key: 'status', width: 10 },
{ header: 'Department Head', key: 'head', width: 22 },
{ header: 'Parent Department', key: 'parent', width: 22 },
{ header: 'Open Roles', key: 'open_roles', width: 12 },
{ header: 'Total Job Posts', key: 'job_posts', width: 14 },
{ header: 'Candidates', key: 'candidates', width: 12 },
{ header: 'Regions', key: 'region_count', width: 10 },
{ header: 'Region / Location', key: 'locations', width: 44 },
{ header: 'Created', key: 'created_at', width: 20 },
{ header: 'Updated', key: 'updated_at', width: 20 },
],
rows: rows.map((r) => ({
name: r.name,
short_code: r.short_code,
subtitle: r.subtitle || '',
description: r.description || '',
status: r.is_active ? 'Active' : 'Inactive',
head: r.department_head_name || '',
parent: r.parent_department_name || '',
open_roles: r.open_roles ?? 0,
job_posts: r.job_posts ?? 0,
candidates: r.candidates ?? 0,
region_count: (r.location || []).length,
locations: (r.location || []).join('; '),
created_at: when(r.created_at),
updated_at: when(r.updated_at),
})),
},
]
}
async function fetchDepartments() {
const res = await departmentsApi.list()
return departmentsApi.toRows(res)
}
export default function Departments() {
const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient()
const canCreate = can('department.create')
const canEdit = can('department.edit')
const canExport = can('department.export')
const canViewRequisitions = can('requisitions.view')
const listQuery = useQuery({
queryKey: qk.departments.list(),
queryFn: fetchDepartments,
})
const rowsAll = listQuery.data ?? []
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
// { row: null | department, editable: boolean }
const [editor, setEditor] = useState(null)
const requisitionsQuery = useQuery({
queryKey: qk.requisitions.openCount(),
queryFn: async () => (await requisitionsApi.countOpen())?.data?.open ?? 0,
enabled: canViewRequisitions,
})
const openRequisitions = requisitionsQuery.data ?? null
const stats = useMemo(
() => ({ ...departmentStats(rowsAll), openRequisitions }),
[rowsAll, openRequisitions],
)
const [exporting, setExporting] = useState(false)
async function exportDepartments() {
setExporting(true)
try {
await exportStyledWorkbook({
filename: `departments-${new Date().toISOString().slice(0, 10)}`,
sheets: buildExportSheets(rowsAll, stats, new Date()),
})
} catch (err) {
toast(friendlyAuthError(err, 'Could not export departments.'), 'error')
} finally {
setExporting(false)
}
}
const rows = useMemo(
() =>
rowsAll.filter((r) => {
if (status === 'active' && !r.is_active) return false
if (status === 'inactive' && r.is_active) return false
if (!q) return true
const hay = [r.name, r.short_code, r.subtitle, r.department_head_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return hay.includes(q.toLowerCase())
}),
[rowsAll, q, status],
)
const pending = listQuery.isPending
return (
<div className="page">
<PageHeader
title="Departments"
sub="Manage departments, ownership, and hiring context used across Jobs, Candidates, and the Inbox filters."
actions={
<>
<button
className="btn btn-secondary"
disabled={!canExport || pending || !rowsAll.length || exporting}
title={!canExport ? 'Requires department.export' : undefined}
onClick={exportDepartments}
>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
<button
className="btn btn-primary"
disabled={!canCreate}
title={!canCreate ? 'Requires department.create' : undefined}
onClick={() => setEditor({ row: null, editable: true })}
>
<Icon name="plus" /> Create Department
</button>
</>
}
/>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Departments" value={pending ? '—' : stats.total} icon="layers" tone="i-indigo" foot="Across the organization" />
<KpiCard label="Candidates Applied" value={pending ? '—' : stats.candidates} icon="users" tone="i-teal" foot="To jobs in these departments" />
<KpiCard
label="Open Requisitions"
value={openRequisitions ?? '—'}
icon="briefcase"
tone="i-amber"
foot={!canViewRequisitions ? 'Requires requisitions.view' : requisitionsQuery.isError ? 'Couldnt load requisitions' : 'Unlinked, or job still open'}
/>
<KpiCard
label="Avg. Open Job Posts"
value={pending ? '—' : stats.openShareLabel}
icon="reports"
tone="i-purple"
foot={pending ? undefined : `${stats.openRoles} open of ${stats.jobPosts} job posts`}
/>
</div>
{pending && (
<div className="card">
<div className="card-body">
<SkeletonRows rows={6} />
</div>
</div>
)}
{listQuery.isError && (
<div className="card">
<div className="card-body">
<EmptyState icon="layers" title="Couldnt load departments">
{friendlyAuthError(listQuery.error, 'Request failed')}
{' '}This screen needs the <code>department.view</code> permission.
</EmptyState>
</div>
</div>
)}
{!pending && !listQuery.isError && (
<>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search name, code, subtitle, or head…"
/>
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{rows.length === 0 ? (
<div className="card">
<div className="card-body">
<EmptyState icon="layers" title={rowsAll.length ? 'No departments match' : 'No departments yet'}>
{rowsAll.length
? 'Try adjusting your search or status filter.'
: 'Create the first department to use it across Jobs, Candidates, and the Inbox.'}
</EmptyState>
</div>
</div>
) : (
<div className="grid g-3">
{rows.map((r) => (
<DepartmentCard
key={r.id}
row={r}
canEdit={canEdit}
onView={() => setEditor({ row: r, editable: false })}
onEdit={() => setEditor({ row: r, editable: true })}
/>
))}
</div>
)}
</>
)}
{editor && (
<DepartmentEditor
row={editor.row}
allowed={editor.editable && (editor.row ? canEdit : canCreate)}
departments={rowsAll}
onClose={() => setEditor(null)}
onEdit={canEdit && editor.row && !editor.editable ? () => setEditor({ ...editor, editable: true }) : null}
onSaved={async () => {
await qc.invalidateQueries({ queryKey: qk.departments.all() })
setEditor(null)
}}
toast={toast}
/>
)}
</div>
)
}
function DepartmentCard({ row, canEdit, onView, onEdit }) {
const tone = toneFor(row.id)
const regions = (row.location || []).length
return (
<div className={`card dept-card tone-${tone}`}>
<div className="card-body">
<div className="dept-card-top">
<div className="dept-card-id">
<div className="dept-icn"><Icon name="layers" /></div>
<div className="min-w-0">
<div className="dept-name">
<span>{row.name}</span>
<span className="code-chip">{row.short_code}</span>
</div>
<div className="cell-sub">{row.subtitle || '—'}</div>
</div>
</div>
{canEdit && (
<button className="act-btn" data-tip="Edit" aria-label={`Edit ${row.name}`} onClick={onEdit}>
<Icon name="edit" />
</button>
)}
</div>
<p className="dept-desc">{row.description || <span className="text-muted">No description</span>}</p>
<div className="dept-stats">
<div className="dept-stat">
<div className="dept-stat-v">{row.open_roles ?? 0}</div>
<div className="dept-stat-l">Open Roles</div>
</div>
<div className="dept-stat">
<div className="dept-stat-v">{row.candidates ?? 0}</div>
<div className="dept-stat-l">Candidates</div>
</div>
<div className="dept-stat">
<div className="dept-stat-v">{regions}</div>
<div className="dept-stat-l">{regions === 1 ? 'Region' : 'Regions'}</div>
</div>
</div>
<div className="divider" />
<div className="dept-card-foot">
<div className="dept-head">
{row.department_head_name ? (
<>
<Avatar name={row.department_head_name} className="dept-avatar" />
<span>{row.department_head_name}</span>
</>
) : (
<span className="text-muted">No department head</span>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={onView}>View</button>
</div>
</div>
</div>
)
}
function blankForm() {
return {
name: '',
short_code: '',
is_active: true,
description: '',
department_head_id: '',
location: [],
subtitle: '',
parent_department_id: '',
}
}
function fromRow(row) {
return {
name: row.name || '',
short_code: row.short_code || '',
is_active: row.is_active !== false,
description: row.description || '',
department_head_id: row.department_head_id || '',
location: row.location || [],
subtitle: row.subtitle || '',
parent_department_id: row.parent_department_id || '',
}
}
function toPayload(f) {
return {
name: f.name.trim(),
short_code: f.short_code.trim().toUpperCase(),
is_active: f.is_active,
description: f.description.trim() || null,
department_head_id: f.department_head_id || null,
location: f.location,
subtitle: f.subtitle.trim() || null,
parent_department_id: f.parent_department_id || null,
}
}
/** Ids of `rootId` and everything under it — none of them may become its parent. */
function selfAndDescendants(rootId, departments) {
const out = new Set([rootId])
let grew = true
while (grew) {
grew = false
departments.forEach((d) => {
if (d.parent_department_id && out.has(d.parent_department_id) && !out.has(d.id)) {
out.add(d.id)
grew = true
}
})
}
return out
}
function DepartmentEditor({ row, allowed, departments, onClose, onEdit, onSaved, toast }) {
const [fields, setFields] = useState(() => (row ? fromRow(row) : blankForm()))
const [errors, setErrors] = useState({})
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
// Loaded when the Department Head dropdown is first opened, not on modal mount.
const [headsRequested, setHeadsRequested] = useState(false)
const headsQuery = useQuery({
queryKey: qk.departments.heads({ roleId: DEPARTMENT_HEAD_ROLE_ID }),
queryFn: async () =>
departmentsApi.toRows(await departmentsApi.listHeads({ roleId: DEPARTMENT_HEAD_ROLE_ID })),
enabled: allowed && headsRequested,
})
const requestHeads = () => {
if (!headsRequested) setHeadsRequested(true)
else if (headsQuery.isError) headsQuery.refetch()
}
const headOptions = useMemo(() => {
const opts = headsQuery.data ?? []
// Keep the saved head selectable even when the picker list is unavailable.
if (row?.department_head_id && !opts.some((o) => o.id === row.department_head_id)) {
return [{ id: row.department_head_id, name: row.department_head_name || 'Current head' }, ...opts]
}
return opts
}, [headsQuery.data, row])
const parentOptions = useMemo(() => {
const blocked = row ? selfAndDescendants(row.id, departments) : new Set()
return departments.filter((d) => !blocked.has(d.id))
}, [departments, row])
const save = useMutation({
mutationFn: () => {
const body = toPayload(fields)
if (row) return departmentsApi.update(row.id, body)
return departmentsApi.create(body)
},
onSuccess: async () => {
toast(row ? 'Department updated' : 'Department created', 'success')
await onSaved()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save the department.'), 'error'),
})
function submit(e) {
e.preventDefault()
const next = {}
if (!fields.name.trim()) next.name = 'Enter the department name'
const code = fields.short_code.trim()
if (!code) next.short_code = 'Enter a short code'
else if (code.length > 10) next.short_code = 'Use 10 characters or fewer'
setErrors(next)
if (Object.keys(next).length) return
save.mutate()
}
const title = !row ? 'Create Department' : allowed ? 'Edit Department' : row.name
return (
<Modal
title={title}
subtitle="Departments power reporting, requisitions, and the Department filter on Candidates & Inbox."
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" type="button" disabled={save.isPending} onClick={onClose}>
{allowed ? 'Cancel' : 'Close'}
</button>
{!allowed && onEdit && (
<button className="btn btn-primary" type="button" onClick={onEdit}>
<Icon name="edit" /> Edit
</button>
)}
{allowed && (
<button className="btn btn-primary" form="department-form" type="submit" disabled={save.isPending}>
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Department'}
</button>
)}
</>
}
>
<form id="department-form" noValidate onSubmit={submit}>
<fieldset disabled={!allowed} style={{ border: 0, margin: 0, padding: 0 }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label htmlFor="dept-name">Department Name <span className="req">*</span></label>
<input
id="dept-name"
className={errors.name ? 'err' : ''}
value={fields.name}
maxLength={120}
placeholder="e.g. Global Emerging Operations"
onChange={(e) => set('name', e.target.value)}
/>
<FieldError>{errors.name}</FieldError>
</div>
<div className="form-field">
<label htmlFor="dept-code">Short Code <span className="req">*</span></label>
<input
id="dept-code"
className={errors.short_code ? 'err' : ''}
value={fields.short_code}
maxLength={10}
placeholder="e.g. GEO"
onChange={(e) => set('short_code', e.target.value.toUpperCase())}
/>
<FieldError>{errors.short_code}</FieldError>
</div>
<div className="form-field">
<label htmlFor="dept-status">Status</label>
<div className="dept-status-toggle">
<label className="switch">
<input
id="dept-status"
type="checkbox"
checked={fields.is_active}
onChange={(e) => set('is_active', e.target.checked)}
/>
<span className="switch-track" />
</label>
<span>{fields.is_active ? 'Active' : 'Inactive'}</span>
</div>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-desc">Description</label>
<textarea
id="dept-desc"
rows={3}
style={{ minHeight: 72 }}
value={fields.description}
placeholder="What this department does and the roles it hires for."
onChange={(e) => set('description', e.target.value)}
/>
</div>
<div className="form-field">
<label htmlFor="dept-head">Department Head</label>
<select
id="dept-head"
value={fields.department_head_id}
onChange={(e) => set('department_head_id', e.target.value)}
onMouseDown={requestHeads}
onFocus={requestHeads}
>
<option value="">
{headsQuery.isFetching ? 'Loading…' : headsQuery.isError ? 'Couldnt load department heads' : 'Not assigned'}
</option>
{headOptions.map((u) => (
<option key={u.id} value={u.id}>{u.email ? `${u.name}${u.email}` : u.name}</option>
))}
</select>
</div>
<div className="form-field">
<label htmlFor="dept-parent">Parent Department</label>
<select
id="dept-parent"
value={fields.parent_department_id}
onChange={(e) => set('parent_department_id', e.target.value)}
>
<option value="">None (top-level)</option>
{parentOptions.map((d) => (
<option key={d.id} value={d.id}>{d.name} ({d.short_code})</option>
))}
</select>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-subtitle">Subtitle</label>
<input
id="dept-subtitle"
value={fields.subtitle}
maxLength={160}
placeholder="e.g. Store Operations"
onChange={(e) => set('subtitle', e.target.value)}
/>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-location">Region / Location</label>
<LocationMultiSelect
id="dept-location"
value={fields.location}
disabled={!allowed}
onChange={(next) => set('location', next)}
/>
</div>
</div>
</fieldset>
</form>
</Modal>
)
}
// Rendering all ~800 cities at once makes typing lag; the search narrows it.
const LOCATION_RENDER_LIMIT = 100
/**
* Chip + search picker over GET /department/locations/fetch ("{City} - {Country}").
* Same markup as Jobs' RecruiterMultiSelect. Value is the list of selected labels;
* the options load the first time the field is focused.
*/
function LocationMultiSelect({ id, value = [], onChange, disabled = false }) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const listRef = useRef(null)
const locationsQuery = useQuery({
queryKey: qk.departments.locations(),
queryFn: async () => departmentsApi.toRows(await departmentsApi.listLocations()),
enabled: open,
staleTime: Infinity,
})
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
// Scroll the modal so the opened list is visible, not below the fold.
useEffect(() => {
if (open) listRef.current?.scrollIntoView({ block: 'nearest' })
}, [open, locationsQuery.isSuccess])
const selected = value || []
const term = q.trim().toLowerCase()
const filtered = (locationsQuery.data ?? []).filter(
(label) => !selected.includes(label) && (!term || label.toLowerCase().includes(term)),
)
function add(label) {
onChange([...selected, label])
setQ('')
}
function remove(label) {
onChange(selected.filter((x) => x !== label))
}
return (
<div className="job-recruiter-multi" ref={root}>
{selected.length > 0 && (
<div className="job-recruiter-chips">
{selected.map((label) => (
<span className="job-recruiter-chip" key={label}>
{label}
<button
type="button"
className="job-recruiter-chip-x"
aria-label={`Remove ${label}`}
disabled={disabled}
onClick={() => remove(label)}
>
×
</button>
</span>
))}
</div>
)}
<input
id={id}
value={open ? q : ''}
disabled={disabled}
placeholder={selected.length ? 'Add another location…' : 'Search city or country…'}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && (
// In the normal flow, not position:absolute .modal-body scrolls, so an
// absolute menu is clipped at its edge. This list grows the modal instead.
<div className="dept-location-list" ref={listRef} role="listbox">
{locationsQuery.isFetching && <div className="dept-location-note">Loading</div>}
{locationsQuery.isError && (
<button type="button" className="dept-location-option text-muted" onClick={() => locationsQuery.refetch()}>
Couldnt load locations retry
</button>
)}
{locationsQuery.isSuccess && filtered.length === 0 && (
<div className="dept-location-note">No matches</div>
)}
{filtered.slice(0, LOCATION_RENDER_LIMIT).map((label) => (
<button type="button" role="option" key={label} className="dept-location-option" onClick={() => add(label)}>
{label}
</button>
))}
{filtered.length > LOCATION_RENDER_LIMIT && (
<div className="dept-location-note">Type to narrow {filtered.length - LOCATION_RENDER_LIMIT} more</div>
)}
</div>
)}
</div>
)
}

View File

@ -27,6 +27,8 @@ import * as jobPostsApi from '../api/jobPosts'
import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions'
import * as departmentsApi from '../api/departments'
import * as offersApi from '../api/offers'
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
import { empTypes } from '../data/seed'
@ -39,7 +41,7 @@ async function fetchJobs() {
return rows.map(jobsApi.toJobView)
}
export function deptValue(j) {
function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
@ -365,7 +367,6 @@ export default function Jobs() {
{editing && (
<EditJobForm
job={editing}
departmentOptions={departmentOptions}
busy={updateJob.isPending}
onClose={() => setEditing(null)}
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
@ -374,7 +375,6 @@ export default function Jobs() {
{creating && (
<JobForm
departmentOptions={departmentOptions}
busy={createJob.isPending}
onClose={() => setCreating(false)}
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
@ -611,6 +611,49 @@ function useRecruiterDirectory() {
})
}
/**
* Department search dropdown over GET /department/names?search=. Options are
* `{id, name}`; the picked row is kept in the list so a selection made from an
* earlier search (or the job's saved department) still renders its name.
*/
function useDepartmentPicker(initialPicked = null) {
const [deptQ, setDeptQ] = useState('')
const [debouncedDeptQ, setDebouncedDeptQ] = useState('')
const [pickedDept, setPickedDept] = useState(initialPicked)
useEffect(() => {
const t = setTimeout(() => setDebouncedDeptQ(deptQ.trim()), 250)
return () => clearTimeout(t)
}, [deptQ])
const departmentsQuery = useQuery({
queryKey: qk.departments.names(debouncedDeptQ),
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: debouncedDeptQ })),
placeholderData: keepPreviousData,
retry: false,
})
const departmentOptions = useMemo(() => {
const rows = departmentsQuery.data ?? []
if (pickedDept && !rows.some((o) => String(o.id) === String(pickedDept.id))) {
return [pickedDept, ...rows]
}
return rows
}, [departmentsQuery.data, pickedDept])
/** Pick by name — the requisition picker only knows its department as text. */
async function pickByName(name) {
const term = String(name || '').trim().toLowerCase()
if (!term) return null
const rows = departmentsApi.toRows(await departmentsApi.listNames({ search: name.trim() }))
const match = rows.find((d) => String(d.name).trim().toLowerCase() === term)
if (match) setPickedDept(match)
return match || null
}
return { departmentOptions, departmentsQuery, setDeptQ, pickedDept, setPickedDept, pickByName }
}
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
const [reqQ, setReqQ] = useState('')
const [debouncedReqQ, setDebouncedReqQ] = useState('')
@ -648,17 +691,43 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
/** The Department field on both job forms: search dropdown, stores department_id. */
function DepartmentSearchSelect({ dept, value, onChange, disabled }) {
return (
<>
<SearchSelect
options={dept.departmentOptions}
value={value}
onChange={(id) => {
onChange(id)
dept.setPickedDept(dept.departmentOptions.find((o) => String(o.id) === String(id)) || null)
}}
onQueryChange={dept.setDeptQ}
placeholder="Search departments…"
disabled={disabled}
loading={dept.departmentsQuery.isPending && !dept.departmentsQuery.data}
allowEmpty
emptyLabel="No department"
/>
{dept.departmentsQuery.isError && (
<p className="text-muted text-sm">Could not load departments.</p>
)}
</>
)
}
function JobForm({ busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
const dept = useDepartmentPicker()
const form = useFormState({
hiring_manager_id: '',
current_recruiter_ids: [],
requisition_id: '',
title: '',
department: '',
department_id: '',
location: '',
employment_type: empTypes[0] || 'Full-time',
vacancies: '1',
@ -733,7 +802,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// The cover image is uploaded separately right after the row exists.
onSubmit({
title: v.title.trim(),
department: v.department.trim() || null,
department_id: v.department_id || null,
location: v.location.trim() || null,
employment_type: v.employment_type || null,
vacancies,
@ -757,7 +826,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// field itself and empty values before building the prompt.
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
department: dept.pickedDept?.name || '',
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
@ -811,7 +880,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
if (opt) {
setPickedReq(opt)
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
if (!form.values.department_id && opt.department) {
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
}
}
}}
onQueryChange={setReqQ}
@ -836,7 +907,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
</div>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
@ -869,16 +940,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input
{...field('department')}
list="job-department-options"
placeholder="e.g. Engineering"
/>
<datalist id="job-department-options">
{departmentOptions.map((d) => <option key={d} value={d} />)}
</datalist>
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
</div>
<div className="form-field">
<div className="field-label-row">
@ -1028,10 +1091,11 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
: null,
j.id,
)
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
const form = useFormState({
requisition_id: j.requisitionId || '',
title: j.title || '',
department: j.department || '',
department_id: j.departmentId || '',
location: j.location || '',
employment_type: j.type || '',
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
@ -1044,7 +1108,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
department: dept.pickedDept?.name || '',
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
@ -1073,7 +1137,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
if (Object.keys(errors).length) return
onSubmit({
title,
department: form.values.department.trim() || null,
department_id: form.values.department_id || null,
location: form.values.location.trim() || null,
employment_type: form.values.employment_type || null,
vacancies: Number(form.values.vacancies) || 1,
@ -1118,7 +1182,9 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
if (opt) {
setPickedReq(opt)
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
if (!form.values.department_id && opt.department) {
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
}
}
}}
onQueryChange={setReqQ}
@ -1141,7 +1207,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
@ -1167,10 +1233,8 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
</div>
<div className="form-field">
<div className="field-label-row">
@ -1239,7 +1303,7 @@ export function JobOwnership({ job, canEdit }) {
<div style={SECTION_LABEL}>Ownership</div>
<div className="form-grid" style={{ marginBottom: 12 }}>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
{canEdit ? (
<SearchSelect
options={managersQuery.data ?? []}

View File

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

View File

@ -275,6 +275,9 @@ function blankForm() {
date_needed: '',
type: '',
job_description: '',
period_from: '',
period_to: '',
jd_available: false,
to_replace: '',
grade: '',
recruitment_title: '',
@ -284,6 +287,7 @@ function blankForm() {
recommended_grade: '',
employee_name: '',
employee_department: '',
entity: '',
initiated_by: '',
initiated_date: '',
recommended_by: '',
@ -310,6 +314,9 @@ function fromRow(row) {
date_needed: toDateInput(pos.date_needed),
type: pos.type || '',
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 || '',
grade: rep.grade || '',
recruitment_title: rep.title || '',
@ -319,6 +326,7 @@ function fromRow(row) {
recommended_grade: rep.recommended_grade || '',
employee_name: ref.employee_name || '',
employee_department: ref.employee_department || '',
entity: ref.entity || '',
initiated_by: row.initiated_by || '',
initiated_date: toDateInput(row.initiated_date),
recommended_by: row.recommended_by || '',
@ -330,7 +338,7 @@ function fromRow(row) {
approved_by_svp: row.approved_by_svp === true,
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_referral: hasAny(ref, ['employee_name', 'employee_department']),
is_referral: hasAny(ref, ['employee_name', 'employee_department', 'entity']),
}
}
@ -343,6 +351,9 @@ function toPayload(f) {
date_needed: emptyToNull(f.date_needed),
type: emptyToNull(f.type),
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_date: emptyToNull(f.initiated_date),
@ -377,10 +388,12 @@ function toPayload(f) {
? {
employee_name: emptyToNull(f.employee_name),
employee_department: emptyToNull(f.employee_department),
entity: emptyToNull(f.entity),
}
: {
employee_name: null,
employee_department: null,
entity: null,
},
}
return body
@ -468,6 +481,22 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
))}
</select>
</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">
<label>Job description</label>
<textarea
@ -477,6 +506,16 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('job_description', e.target.value)}
/>
</div>
<div className="form-field">
<label className="hf-check">
<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>
@ -564,6 +603,10 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
onChange={(e) => set('employee_department', e.target.value)}
/>
</div>
<div className="form-field">
<label>Entity</label>
<input value={fields.entity} onChange={(e) => set('entity', e.target.value)} />
</div>
</div>
)}
</div>
@ -599,7 +642,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · Director HR</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_hr}
@ -615,7 +658,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · VP</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_vp}
@ -631,7 +674,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · SVP</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_svp}

View File

@ -0,0 +1,185 @@
/* Candidate detail is a compact workspace that follows the app theme.
Previous / Next live in the page bar. There is no extra candidate list. */
.candidate-workspace .content { padding: 0 24px 28px; background: var(--bg); }
html[data-theme="dark"] .candidate-workspace .content { background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
.candidate-workspace .cand-page { max-width: 1680px; margin-inline: auto; font-size: 13px; min-width: 0; }
.candidate-workspace .cand-page-bar { gap: 10px; min-height: 44px; margin: 0; padding-block: 8px; }
.candidate-workspace .cand-page-crumb { font-size: 12px; display: flex; align-items: center; min-width: 0; white-space: normal; overflow: visible; }
.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; margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.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-primary, .candidate-dialog .btn-primary { font-weight: 650; }
.candidate-workspace .btn-sm { min-height: 28px; padding: 4px 8px; font-size: 12px; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 15px; height: 15px; 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 button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: var(--primary-border); }
.cw-browse-nav { display: flex; align-items: center; gap: 4px; }
.cw-browse-arrow { width: 32px; padding: 5px; 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-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.cw-name { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.cw-name h1 { margin: 0; font-size: 22px; line-height: 1.2; letter-spacing: -.4px; 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: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.cw-contact a { color: var(--text-3); text-decoration: none; }
.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 svg { width: 14px; height: 14px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 8px; 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-hero, .cw-overview, .cw-card, .cw-column { min-width: 0; max-width: 100%; }
.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 > svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.cw-fact span { display: block; color: var(--text-3); font-size: 11px; line-height: 1.3; margin-bottom: 2px; }
.cw-fact strong { font-size: 12px; font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; }
.cw-tabs { min-width: 0; max-width: 100%; }
.cw-tabs .tabs { gap: 4px; margin-bottom: 12px; min-width: 0; max-width: 100%; }
.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-count { background: var(--bg-sunken); color: var(--text-2); font-size: 11px; min-width: 16px; }
.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: 12px; min-width: 0; }
.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: 8px; margin-bottom: 12px; }
.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: var(--primary); text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
.cw-link svg { width: 14px; height: 14px; }
.cw-info { display: grid; gap: 10px; margin: 0; }
.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: 8px; color: var(--text-3); }
.cw-info dt svg { width: 14px; height: 14px; margin-top: 1px; flex-shrink: 0; }
.cw-info dd { margin: 0; overflow-wrap: anywhere; }
.cw-skills { display: flex; gap: 6px; 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-table-wrap { overflow: auto; min-width: 0; max-width: 100%; }
.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 td { padding: 10px 6px; border-bottom: 1px solid var(--border); }
.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: 12px; font-weight: 550; }
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 3px; }
.cw-applications .badge { font-size: 11px; padding: 3px 7px; }
.cw-applications .is-current { background: linear-gradient(90deg, var(--primary-soft), 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 p + p { margin-top: 8px; }
.cw-empty { color: var(--text-3); font-size: 12.5px; line-height: 1.6; 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-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 svg { width: 14px; height: 14px; }
.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 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-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cw-document-list { display: grid; gap: 8px; }
.candidate-workspace .cw-icon-label { width: 28px; 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 > .cw-card { align-self: start; }
.cw-rating { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cw-rating > span { font-size: 12px; color: var(--primary); }
.cw-rating .rating-stars .rs svg { width: 16px; height: 16px; }
.cw-recruiter { display: flex; align-items: center; gap: 10px; }
.cw-recruiter .avatar { width: 28px; height: 28px; font-size: 11px; }
.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-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 7px 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:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: var(--danger); color: var(--danger-fg); }
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -2px 0 10px; }
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 8px; }
.cw-field-label { display: block; color: var(--text-3); font-size: 11px; margin-bottom: 4px; }
.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-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 16px 22px; }
.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:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 14px; bottom: 2px; background: var(--border); }
.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.55; margin: 4px 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-screening { display: flex; align-items: center; gap: 14px; }
.cw-match { display: grid; justify-items: center; gap: 4px; flex-shrink: 0; }
.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; }
.candidate-dialog { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); }
.candidate-dialog .form-field { margin-top: 16px; }
.candidate-dialog .empty-state { padding: 20px; }
.cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.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: 1560px) {
.candidate-workspace .content { padding-inline: 18px; }
.candidate-workspace .topbar { padding-inline: 18px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr); }
.cw-column-info { grid-column: 1; grid-row: 1; }
.cw-column-main { grid-column: 2; grid-row: 1 / span 2; }
.cw-column-actions { grid-column: 1; grid-row: 2; }
.cw-hero-top { flex-wrap: wrap; }
.cw-hero-actions { margin-left: auto; }
.cw-bottom-grid { grid-template-columns: minmax(0, 1fr); }
.cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 1fr; }
}
@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) {
.candidate-workspace .content { padding: 0 12px 20px; }
.candidate-workspace .topbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; }
.candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; }
.candidate-workspace .topbar-actions { margin-left: 0; }
.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-crumb { flex: 1; min-width: 0; }
.candidate-workspace .cand-page-crumb .cw-crumb-path { display: none; }
.candidate-workspace .cand-page-actions { width: 100%; margin-left: 0; justify-content: flex-end; flex-wrap: nowrap; }
.candidate-workspace .cand-page-actions .btn { flex: 0 0 auto; }
.candidate-workspace .cand-page-crumb .btn { flex-shrink: 0; }
.cw-browse-nav { gap: 2px; }
.cw-browse-arrow { width: 30px; min-height: 30px; padding: 4px; }
.cw-browse-counter { min-width: 44px; font-size: 11px; }
.cw-hero { padding: 12px; gap: 12px; }
.cw-hero .cw-avatar { width: 44px; height: 44px; font-size: 16px; }
.cw-name h1 { font-size: 18px; }
.cw-hero-actions .btn { flex: 1 1 auto; justify-content: center; }
.cw-facts { margin-top: 8px; }
.cw-fact { border-left: 0; padding: 8px 0 0; }
.cw-tabs .tabs { gap: 0; }
.cw-tabs .tab { padding-inline: 12px; min-height: 40px; }
.cw-action-grid .btn { min-height: 40px; }
.cw-info > div { grid-template-columns: 120px minmax(0, 1fr); }
.cw-applications { min-width: 360px; }
.cw-document { flex-wrap: wrap; }
.cw-document-name { min-width: 100px; }
.cw-document-actions { margin-left: auto; }
.cw-bottom-grid > .cw-column { display: flex; }
.cw-tab-content { padding: 12px; }
.cw-screening { align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
.candidate-workspace .tab-pane, .candidate-workspace .btn { animation: none; transition: none; }
}

View File

@ -769,7 +769,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
/* AI field assist (ui/AiFieldAssist.jsx) */
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
.ai-assist { position: relative; display: inline-flex; }
.ai-assist-btn { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: var(--primary); background: transparent; transition: .15s; }
.ai-assist-btn svg { width: 14px; height: 14px; }
@ -1961,6 +1961,10 @@ canvas { width: 100%; max-width: 100%; display: block; }
.hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
/* Checkbox + text on one line, box flush left. Overrides `.form-field input`
(width:100% + padding), which otherwise stretches the checkbox and centres it. */
.hf-check { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; cursor: pointer; }
.hf-check input[type="checkbox"] { width: auto; padding: 0; margin: 0; flex: none; }
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
@ -2314,3 +2318,35 @@ canvas { width: 100%; max-width: 100%; display: block; }
@media (max-width: 400px) {
.hf-summary { grid-template-columns: 1fr; }
}
/* ================= DEPARTMENTS ================= */
.dept-card { --tone: var(--primary); --tone-soft: var(--primary-soft); display: flex; flex-direction: column; }
.dept-card.tone-success { --tone: var(--success); --tone-soft: var(--success-soft); }
.dept-card.tone-warning { --tone: var(--warning); --tone-soft: var(--warning-soft); }
.dept-card.tone-purple { --tone: var(--purple); --tone-soft: var(--purple-soft); }
.dept-card.tone-info { --tone: var(--info); --tone-soft: var(--info-soft); }
.dept-card > .card-body { display: flex; flex-direction: column; flex: 1; }
.dept-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.dept-card-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
.dept-icn { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; flex: none; background: var(--tone-soft); color: var(--tone); }
.dept-icn svg { width: 20px; height: 20px; }
.dept-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: var(--fs-md); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.code-chip { font-size: 11px; font-weight: 700; letter-spacing: .4px; padding: 2px 8px; border-radius: 20px; background: var(--tone-soft); color: var(--tone); }
.dept-desc { font-size: var(--fs-sm); color: var(--text-2); line-height: 1.5; margin: 0 0 16px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.dept-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: auto; }
.dept-stat { text-align: center; min-width: 0; }
.dept-stat-v { font-size: 18px; font-weight: 600; line-height: 1.3; }
.dept-stat-v.is-on, .dept-stat-v.is-off { font-size: var(--fs-sm); line-height: 23px; }
.dept-stat-v.is-on { color: var(--success); }
.dept-stat-v.is-off { color: var(--text-3); }
.dept-stat-l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.dept-card .divider { margin: 14px 0; }
.dept-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.dept-head { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-sm); color: var(--text-2); }
.dept-head > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dept-avatar { width: 26px; height: 26px; font-size: 10px; }
.dept-status-toggle { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--border-strong); border-radius: 9px; font-size: var(--fs-sm); min-height: 40px; }
.dept-location-list { max-height: 320px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-elev); box-shadow: var(--shadow-sm); padding: 4px; }
.dept-location-option { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 7px; font-size: var(--fs-sm); color: var(--text); }
.dept-location-option:hover, .dept-location-option:focus-visible { background: var(--bg-sunken); outline: none; }
.dept-location-note { padding: 8px 10px; font-size: var(--fs-sm); color: var(--text-3); }

View File

@ -193,7 +193,9 @@ export function Stars({ value, onChange, disabled }) {
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => set(n)}
role="radio"
aria-label={`${n} out of 5 stars`}
aria-checked={n === value}
aria-disabled={Boolean(disabled)}
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
>

View File

@ -23,7 +23,7 @@ export default defineConfig({
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
// (e.g. 8001 locally because another service holds 8000).
proxy: {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions)(/|$)': {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions|department)(/|$)': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
changeOrigin: true,
// Several API prefixes double as SPA routes (/jobs, /inbox, …).