Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Department_Module
commit
ff1f91973d
|
|
@ -20,6 +20,10 @@ dist/**/*
|
|||
.claude/
|
||||
.audit.js
|
||||
|
||||
# Local macOS launcher (not shared — machine-specific)
|
||||
Start.command
|
||||
start.command
|
||||
|
||||
# Backups
|
||||
.backup-prebrand/
|
||||
*.bak
|
||||
|
|
@ -91,3 +95,4 @@ nginx.conf
|
|||
smoke.test.mjs
|
||||
**.docx
|
||||
**.docs
|
||||
Annex**
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -34,9 +34,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
|
||||
|
|
@ -139,8 +143,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 +191,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 +219,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:
|
||||
|
|
|
|||
|
|
@ -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 0–100) appears only once all three exist. Returns None when
|
||||
neither evaluation exists. Legacy 1–4 section averages are converted
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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 (320–1536px)')
|
||||
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() }
|
||||
|
|
@ -14,9 +14,11 @@
|
|||
"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",
|
||||
"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"
|
||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node candidate-browse.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -47,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 />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
|
@ -95,6 +103,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 />} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -107,6 +107,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 —
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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'))}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<div className="cand-page-crumb">
|
||||
<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>
|
||||
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
{actions && <div className="cand-page-actions">{actions}</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>
|
||||
<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'}
|
||||
|
|
|
|||
|
|
@ -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 viewer’s 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 candidate’s 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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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) } }}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in New Issue