Compare commits
No commits in common. "main" and "INTERGRATE_UI-" have entirely different histories.
main
...
INTERGRATE
|
|
@ -20,10 +20,6 @@ dist/**/*
|
|||
.claude/
|
||||
.audit.js
|
||||
|
||||
# Local macOS launcher (not shared — machine-specific)
|
||||
Start.command
|
||||
start.command
|
||||
|
||||
# Backups
|
||||
.backup-prebrand/
|
||||
*.bak
|
||||
|
|
@ -92,5 +88,4 @@ frontend/dist/index.html
|
|||
# nothing under either path that should be ignored.
|
||||
frontend/dist/**
|
||||
nginx.conf
|
||||
smoke.test.mjs
|
||||
Annex**
|
||||
smoke.test.mjs
|
||||
654
Main.dc.html
654
Main.dc.html
|
|
@ -1,654 +0,0 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="./vendor/react.js"></script>
|
||||
<script src="./vendor/react-dom.js"></script>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#03171d; --bg-elev:#071e26; --bg-sunken:#0c2933; --border:#1b404b; --border-strong:#315764;
|
||||
--text:#edf7fa; --text-2:#b6ced7; --text-3:#9ebbc6;
|
||||
--primary:#ccfa70; --primary-fg:#14210b; --primary-soft:#ccfa7012;
|
||||
--success:#25e9a5; --success-soft:rgba(37,233,165,.12);
|
||||
--warning:#ffd16e; --warning-soft:rgba(255,209,110,.14);
|
||||
--danger:#ff7c86; --danger-soft:rgba(255,124,134,.14);
|
||||
--info:#82bcff; --info-soft:rgba(130,188,255,.14);
|
||||
--purple:#b6a6ff; --purple-soft:rgba(182,166,255,.14);
|
||||
--teal:#25e9a5; --teal-soft:rgba(37,233,165,.12);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
a{color:inherit;text-decoration:none;}
|
||||
a:hover{color:var(--text);}
|
||||
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;}
|
||||
input,textarea,select{font:inherit;color:inherit;}
|
||||
body{margin:0;font-family:'Neue Montreal','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;color-scheme:dark;}
|
||||
.page-shell{min-height:100%;background:radial-gradient(ellipse at 50% 0,rgba(11,41,48,.3),transparent 58%) var(--bg);}
|
||||
|
||||
/* ---------- icons ---------- */
|
||||
.icon{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;}
|
||||
.icon-14{width:14px;height:14px;} .icon-15{width:15px;height:15px;} .icon-16{width:16px;height:16px;}
|
||||
.icon-17{width:17px;height:17px;} .icon-18{width:18px;height:18px;} .icon-20{width:20px;height:20px;} .icon-22{width:22px;height:22px;}
|
||||
|
||||
/* ---------- topbar ---------- */
|
||||
.topbar{display:flex;align-items:center;gap:22px;min-height:67px;padding:12px 42px;background:#03181e;border-bottom:1px solid var(--border);}
|
||||
.candidate-brand{display:flex;align-items:center;gap:13px;min-width:230px;}
|
||||
.brand-mark{width:36px;height:36px;fill:#25e9a5;}
|
||||
.candidate-brand strong{display:block;font-size:18px;line-height:1.25;letter-spacing:-.4px;}
|
||||
.candidate-brand small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
|
||||
.menu-toggle{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
|
||||
.menu-toggle:hover{background:var(--bg-sunken);}
|
||||
.topbar-search{position:relative;flex:1;max-width:520px;}
|
||||
.topbar-search input{width:100%;height:38px;padding:0 14px 0 38px;border-radius:8px;background:#0c2832;border:1px solid var(--border);color:var(--text);font-size:13px;}
|
||||
.topbar-search input::placeholder{color:var(--text-3);}
|
||||
.search-icn{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-3);}
|
||||
.topbar-actions{display:flex;align-items:center;gap:12px;margin-left:auto;}
|
||||
.icon-btn{position:relative;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;color:var(--text-2);}
|
||||
.icon-btn:hover{background:var(--bg-sunken);color:var(--text);}
|
||||
.dot-red{position:absolute;top:7px;right:7px;width:7px;height:7px;border-radius:50%;background:var(--danger);border:2px solid #03181e;}
|
||||
.topbar-divider{width:1px;height:24px;background:var(--border);}
|
||||
.profile-btn{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 5px;border-radius:30px;}
|
||||
.profile-btn:hover{background:var(--bg-sunken);}
|
||||
.avatar{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;font-weight:600;font-size:13px;color:#071720;flex-shrink:0;}
|
||||
.avatar-grad{background:#a19df5;}
|
||||
.profile-meta{display:flex;flex-direction:column;line-height:1.2;text-align:left;}
|
||||
.profile-name{font-weight:600;font-size:13px;}
|
||||
.profile-role{font-size:11.5px;color:var(--text-3);}
|
||||
.chev{color:var(--text-3);}
|
||||
|
||||
/* ---------- page shell ---------- */
|
||||
.content{padding:0 42px 40px;}
|
||||
.cand-page{max-width:1740px;margin-inline:auto;font-size:13px;}
|
||||
.cand-page-bar{display:flex;align-items:center;gap:16px;min-height:58px;padding-block:16px;}
|
||||
.cand-page-crumb{font-size:12px;color:var(--text-3);flex:1;}
|
||||
.cand-page-crumb strong{color:var(--text);}
|
||||
.cand-page-crumb span{margin:0 8px;}
|
||||
|
||||
/* ---------- buttons / badges ---------- */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:7px 12px;border-radius:7px;font-weight:500;font-size:12px;white-space:nowrap;border:1px solid transparent;transition:.15s;}
|
||||
.btn-secondary{border:1px solid var(--border);color:var(--text);background:linear-gradient(120deg,#0c2730,#071e26);}
|
||||
.btn-secondary:hover{background:#13333d;border-color:#39606b;}
|
||||
.btn-primary{color:var(--primary-fg);border:1px solid #c5ed6e;background:linear-gradient(105deg,#d3fd80,#c9f86b);font-weight:650;}
|
||||
.btn-primary:hover{background:#dcff9b;}
|
||||
.btn-sm{min-height:30px;padding:5px 8px;}
|
||||
.btn:disabled{cursor:not-allowed;opacity:.45;}
|
||||
.cw-danger{border:1px solid #ae4a55;color:#ff7c86;background:#2a172055;}
|
||||
.cw-danger:hover:not(:disabled){background:#50232b;}
|
||||
.star-btn.on{color:var(--primary);border-color:#788e49;}
|
||||
|
||||
.badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;white-space:nowrap;}
|
||||
.badge::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;}
|
||||
.st-blue{color:var(--info);background:var(--info-soft);}
|
||||
.st-purple{color:var(--purple);background:var(--purple-soft);}
|
||||
.st-amber{color:var(--warning);background:var(--warning-soft);}
|
||||
.st-indigo{color:var(--primary);background:var(--primary-soft);}
|
||||
.st-teal{color:var(--teal);background:var(--teal-soft);}
|
||||
.st-green{color:var(--success);background:var(--success-soft);}
|
||||
.st-red{color:var(--danger);background:var(--danger-soft);}
|
||||
.st-gray{color:var(--text-2);background:var(--bg-sunken);}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.cw-hero{display:flex;gap:24px;padding:22px 24px 20px;border:1px solid var(--border);border-radius:13px;background:linear-gradient(110deg,#09252e,#061d25 70%,#09252c);}
|
||||
.cw-avatar{width:78px;height:78px;font-size:28px;flex-shrink:0;}
|
||||
.cw-hero-body,.cw-identity{flex:1;min-width:0;}
|
||||
.cw-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;}
|
||||
.cw-name{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px;}
|
||||
.cw-name h1{margin:0;font-size:28px;line-height:1.2;letter-spacing:-.7px;font-weight:650;}
|
||||
.cw-contact{display:flex;flex-wrap:wrap;gap:9px 22px;color:var(--text-3);font-size:12px;}
|
||||
.cw-contact>*{display:inline-flex;align-items:center;gap:8px;}
|
||||
.cw-external{color:#9dd3f1 !important;text-decoration:underline;text-underline-offset:3px;}
|
||||
.cw-hero-actions{display:flex;gap:10px;flex-shrink:0;}
|
||||
.cw-facts{display:grid;grid-template-columns:1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr;margin-top:22px;}
|
||||
.cw-fact{display:flex;align-items:center;gap:12px;min-width:0;padding:0 16px;border-left:1px solid var(--border);}
|
||||
.cw-fact:first-child{border-left:0;padding-left:0;}
|
||||
.cw-fact:last-child{padding-right:0;}
|
||||
.cw-fact>svg{color:#c3dce4;}
|
||||
.cw-fact span{display:block;color:var(--text-3);font-size:12px;margin-bottom:4px;}
|
||||
.cw-fact strong{font-size:13px;font-weight:500;}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
.cw-tabs{margin-top:16px;}
|
||||
.tabs{display:flex;gap:10px;border-bottom:1px solid var(--border);overflow-x:auto;}
|
||||
.tab{display:inline-flex;align-items:center;gap:8px;min-height:55px;padding:12px 20px;font-size:13px;font-weight:400;color:var(--text-2);border-bottom:3px solid transparent;margin-bottom:-1px;white-space:nowrap;}
|
||||
.tab:hover{color:var(--text);}
|
||||
.tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600;}
|
||||
.tab-count{background:#153941;color:#cbdee4;font-size:11px;min-width:18px;text-align:center;padding:1px 6px;border-radius:20px;}
|
||||
.tab.active .tab-count{background:var(--primary-soft);color:var(--primary);}
|
||||
|
||||
/* ---------- overview grid ---------- */
|
||||
.cw-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.65fr) minmax(0,.99fr);gap:16px;align-items:start;margin-top:16px;}
|
||||
.cw-column{display:flex;flex-direction:column;gap:14px;min-width:0;}
|
||||
.cw-card{min-width:0;padding:18px 17px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(120deg,#09232c,#061e26 90%);}
|
||||
.cw-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px;}
|
||||
.cw-card-head h2{font-size:15px;font-weight:650;letter-spacing:-.2px;margin:0;}
|
||||
.cw-link{display:inline-flex;align-items:center;gap:6px;color:#b4ed91;text-decoration:underline;text-underline-offset:3px;font-size:12px;}
|
||||
.cw-info{display:grid;gap:15px;margin:0;}
|
||||
.cw-info>div{display:grid;grid-template-columns:minmax(115px,.9fr) minmax(0,1.4fr);gap:12px;line-height:1.4;font-size:12px;}
|
||||
.cw-info dt{display:flex;align-items:flex-start;gap:10px;color:var(--text-3);margin:0;}
|
||||
.cw-info dd{margin:0;}
|
||||
.cw-skills{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.cw-skills>span{padding:6px 10px;border:1px solid #284b57;border-radius:12px;background:#102d38;color:#e0edf3;font-size:12px;}
|
||||
.cw-table-wrap{overflow:auto;}
|
||||
.cw-applications{width:100%;border-collapse:collapse;font-size:12px;text-align:left;}
|
||||
.cw-applications th{color:#bad1dc;text-transform:uppercase;letter-spacing:.4px;font-size:11px;font-weight:500;border-top:1px solid #15343d;border-bottom:1px solid #15343d;padding:9px 6px;white-space:nowrap;}
|
||||
.cw-applications td{padding:13px 6px;border-bottom:1px solid #15343d;}
|
||||
.cw-applications td:first-child,.cw-applications th:first-child{padding-left:0;}
|
||||
.cw-applications td:last-child,.cw-applications th:last-child{padding-right:0;}
|
||||
.cw-applications tr:last-child td{border-bottom:0;}
|
||||
.cw-applications td:nth-child(2){color:var(--text-2);white-space:nowrap;}
|
||||
.cw-applications strong{display:block;font-size:13px;font-weight:550;}
|
||||
.cw-applications small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
|
||||
.cw-applications .badge{font-size:11px;padding:3px 7px;}
|
||||
.cw-applications .is-current{background:linear-gradient(90deg,rgba(18,53,52,.22),transparent);}
|
||||
.cw-summary{margin:0;color:var(--text-2);font-size:13px;line-height:1.8;}
|
||||
.cw-empty{color:var(--text-3);font-size:13px;line-height:1.7;margin:0;}
|
||||
.cw-document{display:flex;align-items:center;gap:11px;padding:10px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(100deg,#0d2c36,#0a232b);}
|
||||
.cw-document-icon{display:flex;flex-direction:column;align-items:center;justify-content:center;width:29px;height:36px;background:linear-gradient(135deg,#ff7575,#df424d);border-radius:4px;color:#fff;flex-shrink:0;}
|
||||
.cw-document-icon small{font-size:7px;margin-top:2px;}
|
||||
.cw-document-name{flex:1;min-width:0;}
|
||||
.cw-document-name strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500;}
|
||||
.cw-document-name small{display:block;color:var(--text-3);font-size:11px;margin-top:4px;}
|
||||
.cw-document-actions{display:flex;gap:6px;flex-shrink:0;}
|
||||
.cw-document-list{display:grid;gap:9px;}
|
||||
.cw-bottom-grid{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:14px;}
|
||||
.cw-bottom-grid .cw-card{padding:17px;}
|
||||
.cw-rating{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.cw-rating>span{font-size:12px;color:var(--primary);}
|
||||
.rating-stars{display:inline-flex;gap:3px;}
|
||||
.rating-stars .rs{color:var(--border-strong);}
|
||||
.rating-stars .rs svg{width:18px;height:18px;}
|
||||
.rating-stars .rs.on{color:var(--warning);}
|
||||
.rating-stars .rs.on svg{fill:currentColor;}
|
||||
.cw-recruiter{display:flex;align-items:center;gap:10px;}
|
||||
.cw-recruiter .avatar{width:32px;height:32px;font-size:12px;}
|
||||
.cw-recruiter strong{display:block;font-size:12px;font-weight:500;}
|
||||
.cw-recruiter small{display:block;color:var(--text-3);font-size:12px;margin-top:3px;}
|
||||
.cw-action-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;}
|
||||
.cw-action-grid .btn{font-size:12px;justify-content:flex-start;padding:8px;white-space:normal;text-align:left;}
|
||||
.cw-active-application{font-size:12px;color:var(--text-3);margin:-4px 0 12px;}
|
||||
.cw-status-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:10px;}
|
||||
.cw-field-label{display:block;color:var(--text-3);font-size:12px;margin-bottom:5px;}
|
||||
.cw-status-grid select,.cw-status-value{width:100%;min-height:37px;padding:8px 10px;background:#0c2933;color:var(--text);border:1px solid var(--border);border-radius:7px;font-size:12px;}
|
||||
.cw-status-grid select{appearance:none;}
|
||||
.cw-status-value{display:flex;align-items:center;gap:8px;}
|
||||
.cw-status-dot{width:7px;height:7px;background:var(--success);border-radius:50%;flex-shrink:0;}
|
||||
.cw-status-dot.is-closed{background:var(--text-3);}
|
||||
.cw-activity{list-style:none;margin:0;padding:0;}
|
||||
.cw-activity li{position:relative;padding:0 0 23px 24px;}
|
||||
.cw-activity li:last-child{padding-bottom:0;}
|
||||
.cw-activity li::before{content:'';position:absolute;left:0;top:4px;width:10px;height:10px;background:#59a8ff;border:2px solid #245788;border-radius:50%;}
|
||||
.cw-activity li:not(:last-child)::after{content:'';position:absolute;width:1px;left:4px;top:15px;bottom:3px;background:#315662;}
|
||||
.cw-activity-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
|
||||
.cw-activity strong{font-size:12px;font-weight:550;}
|
||||
.cw-activity time{color:var(--text-3);font-size:11px;white-space:nowrap;}
|
||||
.cw-activity p{color:var(--text-3);font-size:12px;line-height:1.65;margin:5px 0 0;}
|
||||
.cw-activity small{color:var(--text-3);font-size:11px;}
|
||||
.cw-screening{display:flex;align-items:center;gap:18px;}
|
||||
.cw-match{display:grid;justify-items:center;gap:6px;flex-shrink:0;}
|
||||
.cw-match small{color:var(--text-3);font-size:12px;}
|
||||
.score-ring{--pct:0;position:relative;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(var(--sc-color) calc(var(--pct)*1%),var(--bg-sunken) 0);}
|
||||
.score-ring::after{content:'';position:absolute;inset:4px;border-radius:50%;background:var(--bg-elev);}
|
||||
.score-ring span{position:relative;z-index:1;font-size:10px;font-weight:700;}
|
||||
.cw-tab-content{padding:22px;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;margin-top:16px;}
|
||||
.cw-muted{color:var(--text-3);}
|
||||
|
||||
/* ---------- secondary-tab content (lighter fidelity, same tokens) ---------- */
|
||||
.simple-row{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--border);}
|
||||
.simple-row:first-child{border-top:0;padding-top:0;}
|
||||
.simple-row-icn{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;flex-shrink:0;background:var(--bg-sunken);color:var(--info);}
|
||||
.simple-row-main{flex:1;min-width:0;}
|
||||
.simple-row-title{font-size:13px;font-weight:600;}
|
||||
.simple-row-sub{font-size:12px;color:var(--text-3);margin-top:3px;}
|
||||
.note-compose textarea{width:100%;min-height:64px;padding:10px 12px;background:#0c2933;border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit;resize:vertical;margin-bottom:10px;}
|
||||
.note-compose textarea::placeholder{color:var(--text-3);}
|
||||
.section-label{font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:.4px;margin-bottom:12px;}
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div class="page-shell">
|
||||
|
||||
<header class="topbar">
|
||||
<a class="candidate-brand" href="#">
|
||||
<svg class="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true"><path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z"/></svg>
|
||||
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Toggle menu">
|
||||
<svg class="icon icon-20" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="topbar-search">
|
||||
<svg class="icon icon-16 search-icn" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" placeholder="Search candidates, jobs, requisitions…" />
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-btn" aria-label="Notifications">
|
||||
<svg class="icon icon-20" viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="dot-red"></span>
|
||||
</button>
|
||||
<div class="topbar-divider"></div>
|
||||
<button class="profile-btn">
|
||||
<span class="avatar avatar-grad">MK</span>
|
||||
<span class="profile-meta"><span class="profile-name">Meera Khan</span><span class="profile-role">Recruiter</span></span>
|
||||
<svg class="icon icon-16 chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<div class="cand-page">
|
||||
|
||||
<div class="cand-page-bar">
|
||||
<button class="btn btn-secondary btn-sm"><svg class="icon icon-16" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>Back</button>
|
||||
<div class="cand-page-crumb">Candidates <span>/</span> <strong>Ada Lovelace</strong></div>
|
||||
<div class="cand-page-actions">
|
||||
<button class="btn btn-secondary star-btn {{favClass}}" onClick="{{favoriteToggle}}">
|
||||
<svg class="icon icon-16" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>{{favLabel}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ HERO ============ -->
|
||||
<header class="cw-hero">
|
||||
<span class="avatar cw-avatar" style="background:linear-gradient(145deg,#b2acff,#9395f0)">AL</span>
|
||||
<div class="cw-hero-body">
|
||||
<div class="cw-hero-top">
|
||||
<div class="cw-identity">
|
||||
<div class="cw-name">
|
||||
<h1>Ada Lovelace</h1>
|
||||
<span class="badge {{stageClass}}">{{stage}}</span>
|
||||
</div>
|
||||
<div class="cw-contact">
|
||||
<a href="mailto:ada.lovelace@example.com"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>ada.lovelace@example.com</a>
|
||||
<a href="tel:+15552147788"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/></svg>+1 (555) 214-7788</a>
|
||||
<span><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Austin, TX</span>
|
||||
<a class="cw-external" href="#" target="_blank" rel="noopener noreferrer"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>LinkedIn profile</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cw-hero-actions">
|
||||
<button class="btn btn-secondary"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download CV</button>
|
||||
<button class="btn btn-primary"><svg class="icon icon-16" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Open Resume</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cw-facts">
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg><div><span>Applied for</span><strong>Senior Backend Engineer</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg><div><span>Applied on</span><strong>Mar 12, 2026</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg><div><span>Source</span><strong>Careers page</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg><div><span>Current company</span><strong>Meridian Systems</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><div><span>Experience</span><strong>6 years</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg><div><span>Education</span><strong>MSc Computer Science</strong></div></div>
|
||||
<div class="cw-fact"><svg class="icon icon-20" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><div><span>Total applications</span><strong>{{appCount}}</strong></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ TABS ============ -->
|
||||
<div class="cw-tabs">
|
||||
<div class="tabs">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="8">
|
||||
<button class="{{t.cls}}" onClick="{{t.pick}}">{{t.label}}<sc-if value="{{t.hasCount}}" hint-placeholder-val="{{true}}"><span class="tab-count">{{t.count}}</span></sc-if></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ OVERVIEW ============ -->
|
||||
<sc-if value="{{showOverview}}" hint-placeholder-val="{{true}}">
|
||||
<div class="cw-overview">
|
||||
|
||||
<div class="cw-column">
|
||||
<section class="cw-card">
|
||||
<div class="cw-card-head"><h2>Candidate Information</h2></div>
|
||||
<dl class="cw-info">
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Full name</dt><dd>Ada Lovelace</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Email</dt><dd>ada.lovelace@example.com</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/></svg>Phone</dt><dd>+1 (555) 214-7788</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Location</dt><dd>Austin, TX</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>Current company</dt><dd>Meridian Systems</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>Current title</dt><dd>Senior Backend Engineer</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Experience</dt><dd>6 years</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>Education</dt><dd>MSc Computer Science — Imperial College London</dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>LinkedIn</dt><dd><a class="cw-external" href="#" target="_blank" rel="noopener noreferrer">View LinkedIn profile</a></dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Notice period</dt><dd><sc-if value="{{hasNoticePeriod}}" hint-placeholder-val="{{true}}">{{noticePeriod}}</sc-if><sc-if value="{{noNoticePeriod}}" hint-placeholder-val="{{false}}"><span class="cw-muted">—</span></sc-if></dd></div>
|
||||
<div><dt><svg class="icon icon-15" viewBox="0 0 24 24"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Expected salary</dt><dd><sc-if value="{{hasExpectedSalary}}" hint-placeholder-val="{{true}}">{{expectedSalary}}</sc-if><sc-if value="{{noExpectedSalary}}" hint-placeholder-val="{{false}}"><span class="cw-muted">—</span></sc-if></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="cw-card">
|
||||
<div class="cw-card-head"><h2>Skills & 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,14 +16,10 @@ 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,13 +34,9 @@ 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
|
||||
|
|
@ -143,12 +139,8 @@ 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,
|
||||
|
|
@ -191,12 +183,6 @@ 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:
|
||||
|
|
@ -219,8 +205,6 @@ 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,8 +80,7 @@ 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"},
|
||||
{"key": "analytical_reasoning", "label": "Analytical Reasoning"},
|
||||
{"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
|
||||
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
|
||||
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
|
||||
],
|
||||
|
|
@ -110,41 +109,27 @@ 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": "HR Evaluation",
|
||||
"title": "Cultural Fit",
|
||||
"source": "Annexure E - Interview Evaluation Form",
|
||||
"scale_note": RATING_SCALE_NOTE,
|
||||
"sections": [
|
||||
{
|
||||
"key": "cultural",
|
||||
"title": "HR EVALUATION",
|
||||
"average_label": "HR EVALUATION SECTION",
|
||||
"title": "CULTURAL FIT",
|
||||
"average_label": "CULTURAL FIT SECTION",
|
||||
"criteria": [
|
||||
{"key": "basic_jd_requirement", "label": "Basic JD requirement"},
|
||||
{"key": "company_values", "label": "Alignment with Company Culture"},
|
||||
{"key": "company_values", "label": "Alignment with Company Values"},
|
||||
{"key": "professionalism", "label": "Professionalism & Integrity"},
|
||||
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
|
||||
{"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"},
|
||||
{"key": "adaptability", "label": "Adaptability to Change"},
|
||||
{"key": "work_ethic", "label": "Work Ethic & Reliability"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"fields": (
|
||||
_EVALUATION_HEADER_FIELDS
|
||||
+ [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}]
|
||||
+ [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
|
||||
+ _EVALUATION_FOOTER_FIELDS
|
||||
),
|
||||
"has_recommendation": True,
|
||||
|
|
@ -189,7 +174,6 @@ 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"},
|
||||
|
|
@ -370,10 +354,7 @@ 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" 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.
|
||||
behavioral averages, the latest cultural_fit row the cultural average.
|
||||
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,9 +85,6 @@ 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,
|
||||
|
|
@ -101,7 +98,6 @@ 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),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from __future__ import annotations
|
|||
import re
|
||||
from functools import wraps
|
||||
|
||||
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_NAME,NO_PHONE
|
||||
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_PHONE
|
||||
from global_cities import CITY_BY_KEY,CITY_RE
|
||||
|
||||
_CITY_SENTINELS=frozenset({
|
||||
|
|
@ -101,12 +101,15 @@ def _clean_phone(value,resume_text):
|
|||
text=(value or "").strip()
|
||||
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
||||
return None
|
||||
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
||||
digits=_phone_digits(text)
|
||||
if _phone_score(digits)<0:
|
||||
return None
|
||||
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
||||
digits=re.sub(r"\D","",text)
|
||||
if digits.startswith("00"):
|
||||
digits=digits[2:]
|
||||
if len(digits)<10 or len(digits)>15:
|
||||
return None
|
||||
if (resume_text or "").strip():
|
||||
haystack=re.sub(r"\D","",resume_text)
|
||||
if digits not in haystack:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
|
|
@ -191,20 +194,6 @@ def _clean_skills(value,resume_text):
|
|||
return kept[:30]
|
||||
|
||||
|
||||
def _clean_name(value,resume_text):
|
||||
"""Full name from the resume header. Invented / email-shaped values drop."""
|
||||
text=(value or "").strip()
|
||||
if not text or text.lower() in {NO_NAME.lower(),"none","null","n/a","-"}:
|
||||
return ""
|
||||
if "@" in text or len(text)>120:
|
||||
return ""
|
||||
haystack=(resume_text or "").lower()
|
||||
first=text.split()[0].lower()
|
||||
if haystack and first not in haystack:
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _clean_years(value,resume_text):
|
||||
"""Whole years of experience, bounded 0-60. Anything else is None.
|
||||
|
||||
|
|
@ -244,7 +233,6 @@ clamp_phone=clamp_field("phone",_clean_phone)
|
|||
clamp_skills=clamp_field("skills",_clean_skills)
|
||||
clamp_years_experience=clamp_field("years_experience",_clean_years)
|
||||
clamp_city=clamp_field("city",_clean_city)
|
||||
clamp_candidate_name=clamp_field("candidate_name",_clean_name)
|
||||
|
||||
|
||||
@require_json_object
|
||||
|
|
@ -256,19 +244,18 @@ clamp_candidate_name=clamp_field("candidate_name",_clean_name)
|
|||
@clamp_skills
|
||||
@clamp_years_experience
|
||||
@clamp_city
|
||||
@clamp_candidate_name
|
||||
def parse_employment_response(data,resume_text=""):
|
||||
"""Pull name, company, education, title, linkedin_url, phone, city, skills, and years
|
||||
"""Pull company, education, title, linkedin_url, phone, city, skills, and years
|
||||
from the agent JSON.
|
||||
|
||||
skills, years_experience, and candidate_name default to []/None/"" when the
|
||||
key is absent, so a model reply predating the extended prompt still parses.
|
||||
skills and years_experience default to []/None when the key is absent, so a
|
||||
model reply predating the extended prompt still parses — the inbox match
|
||||
path reads the other five keys and must not break on a partial response.
|
||||
"""
|
||||
def as_str(key):
|
||||
value=data.get(key)
|
||||
return value.strip() if isinstance(value,str) else ""
|
||||
return {
|
||||
"candidate_name":as_str("candidate_name"),
|
||||
"current_employment":as_str("current_employment"),
|
||||
"education":as_str("education"),
|
||||
"current_title":as_str("current_title"),
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ async def run_employment_agent(*,resume_text=""):
|
|||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
return {
|
||||
"candidate_name":"",
|
||||
"current_employment":NO_COMPANY,
|
||||
"education":EDUCATION,
|
||||
"current_title":CURRENT_TITLE,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ Call like the rest of the backend:
|
|||
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
||||
url=fields["linkedin_url"]
|
||||
|
||||
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
|
||||
stacked parser cannot recurse into itself.
|
||||
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
|
||||
parser cannot recurse into itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,43 +23,16 @@ from employment_agent.decorators import (
|
|||
prefer_extracted_phone,
|
||||
)
|
||||
|
||||
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
|
||||
_DASH_TO_HYPHEN=str.maketrans({
|
||||
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
|
||||
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
|
||||
"\uFF0D":"-",
|
||||
})
|
||||
_STRIP_INVISIBLE="".join((
|
||||
"\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff",
|
||||
))
|
||||
_DIGIT_TO_ASCII=str.maketrans({
|
||||
**{chr(0x0660+i):str(i) for i in range(10)},
|
||||
**{chr(0x06F0+i):str(i) for i in range(10)},
|
||||
**{chr(0xFF10+i):str(i) for i in range(10)},
|
||||
})
|
||||
_OCR_O=re.compile(r"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
|
||||
_DIGIT_GROUP=re.compile(r"\+?\d+")
|
||||
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
|
||||
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
|
||||
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
|
||||
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
|
||||
|
||||
|
||||
def _normalize_phone_text(text:str) -> str:
|
||||
raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN)
|
||||
raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ")
|
||||
raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n")
|
||||
for ch in _STRIP_INVISIBLE:
|
||||
raw=raw.replace(ch,"")
|
||||
return _OCR_O.sub("0",raw)
|
||||
|
||||
|
||||
def _digits_only(raw:str) -> str:
|
||||
return re.sub(r"\D","",_normalize_phone_text(raw or ""))
|
||||
_PK_MOBILE=re.compile(
|
||||
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
|
||||
)
|
||||
_PHONE_SPAN=re.compile(
|
||||
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
|
||||
)
|
||||
|
||||
|
||||
def _phone_digits(raw:str) -> str:
|
||||
digits=_digits_only(raw)
|
||||
digits=re.sub(r"\D","",raw or "")
|
||||
if digits.startswith("00"):
|
||||
digits=digits[2:]
|
||||
return digits
|
||||
|
|
@ -81,90 +54,37 @@ def _phone_score(digits:str) -> int:
|
|||
return n
|
||||
|
||||
|
||||
def _phone_keys(digits:str) -> set[str]:
|
||||
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
|
||||
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
|
||||
if d.startswith("00"):
|
||||
d=d[2:]
|
||||
keys={d}
|
||||
if d.startswith("92") and len(d)>=12:
|
||||
rest=d[2:]
|
||||
keys.add(rest)
|
||||
if rest.startswith("3"):
|
||||
keys.add("0"+rest)
|
||||
if d.startswith("0") and len(d)>=11:
|
||||
keys.add(d[1:])
|
||||
keys.add("92"+d[1:])
|
||||
if d.startswith("3") and len(d)==10:
|
||||
keys.add("0"+d)
|
||||
keys.add("92"+d)
|
||||
return {k for k in keys if len(k)>=10}
|
||||
|
||||
|
||||
def phone_in_resume(digits:str,resume_text:str) -> bool:
|
||||
"""True when this number (or its 03 / +92 twin) appears in the CV digits."""
|
||||
haystack=_digits_only(resume_text)
|
||||
if not haystack:
|
||||
return True
|
||||
return any(key in haystack for key in _phone_keys(digits))
|
||||
|
||||
|
||||
def _tidy_raw(raw:str) -> str:
|
||||
compact=re.sub(r"[\n\r]+"," ",raw or "")
|
||||
compact=re.sub(r"[ \t]+"," ",compact)
|
||||
return compact.strip(" \t-./()[]{},:|•·∙_")
|
||||
|
||||
|
||||
def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||
value=_tidy_raw(raw)
|
||||
score=_phone_score(_phone_digits(value))
|
||||
if score>best_score:
|
||||
return value,score
|
||||
return best,best_score
|
||||
|
||||
|
||||
def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||
groups=list(_DIGIT_GROUP.finditer(text))
|
||||
for i,start_g in enumerate(groups):
|
||||
acc=start_g.group(0)
|
||||
end=start_g.end()
|
||||
best,best_score=_consider(acc,best,best_score)
|
||||
for nxt in groups[i+1:]:
|
||||
gap=text[end:nxt.start()]
|
||||
if not _GAP_OK.match(gap):
|
||||
break
|
||||
nxt_digits=nxt.group(0).lstrip("+")
|
||||
if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10:
|
||||
break
|
||||
combined=_phone_digits(acc+nxt.group(0))
|
||||
if len(combined)>15:
|
||||
break
|
||||
acc=text[start_g.start():nxt.end()]
|
||||
end=nxt.end()
|
||||
best,best_score=_consider(acc,best,best_score)
|
||||
return best,best_score
|
||||
|
||||
|
||||
def scan_phone(text:str) -> str|None:
|
||||
"""Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me."""
|
||||
haystack=_normalize_phone_text(text or "")
|
||||
best,best_score=None,-1
|
||||
best,best_score=_scan_digit_groups(haystack,best,best_score)
|
||||
for pattern in (_WA_ME,_TEL_URI):
|
||||
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
|
||||
best=None
|
||||
best_score=-1
|
||||
haystack=text or ""
|
||||
for pattern in (_PK_MOBILE,_PHONE_SPAN):
|
||||
for match in pattern.finditer(haystack):
|
||||
best,best_score=_consider(match.group(1),best,best_score)
|
||||
raw=re.sub(r"[\n\r]+"," ",match.group(0))
|
||||
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
|
||||
score=_phone_score(_phone_digits(raw))
|
||||
if score>best_score:
|
||||
best_score=score
|
||||
best=raw
|
||||
if best_score>=180:
|
||||
return best
|
||||
return best
|
||||
|
||||
|
||||
def prefer_full_phone(*candidates) -> str|None:
|
||||
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
|
||||
best,best_score=None,-1
|
||||
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
|
||||
best=None
|
||||
best_n=-1
|
||||
for raw in candidates:
|
||||
value=(raw or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
best,best_score=_consider(value,best,best_score)
|
||||
return best if best_score>=0 else None
|
||||
n=len(_phone_digits(value))
|
||||
if n>=10 and n>best_n:
|
||||
best_n=n
|
||||
best=value
|
||||
return best
|
||||
|
||||
|
||||
def _as_str(data,key):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ CURRENT_TITLE="No JOB POSITION MENTIONED"
|
|||
NO_LINKEDIN="no linkedin url mentioned"
|
||||
NO_PHONE="no phone number mentioned"
|
||||
NO_CITY="no city mentioned"
|
||||
NO_NAME="no name mentioned"
|
||||
|
||||
CITY_POLICY="""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality.
|
||||
- Identify the city if possible. Map it to exactly one city name from the country→cities list supplied below. Pakistan is in that list along with every other country — do not prefer one country.
|
||||
|
|
@ -30,13 +29,12 @@ CITY_POLICY="""- Return ONE proper city name only — the city, not an area, tow
|
|||
def prompt():
|
||||
return f"""You are an HR-ATS recruiting assistant.
|
||||
|
||||
You are given CV/resume text. Identify the candidate's full name, CURRENT employer company
|
||||
You are given CV/resume text. Identify the candidate's CURRENT employer company
|
||||
name, their education (degree / school), their current job title, their
|
||||
LinkedIn profile URL, their phone number, their city of residence, their skills,
|
||||
and their total years of professional experience, when present.
|
||||
|
||||
Rules:
|
||||
- Return only the candidate name that appears in the resume header.
|
||||
- Return only the company name that appears in the resume text for the ongoing / most recent role.
|
||||
- Return only education that appears in the resume text.
|
||||
- Return only job title that appears in the resume text.
|
||||
|
|
@ -47,11 +45,6 @@ Rules:
|
|||
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
|
||||
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
|
||||
|
||||
candidate_name (its own key — a string or the no-name sentinel):
|
||||
- The candidate's full name exactly as written on the resume header / contact block.
|
||||
- Do not invent a name from the email local-part, file name, or LinkedIn slug.
|
||||
- If none is stated, return exactly: {NO_NAME}
|
||||
|
||||
skills (its own key — a JSON array of strings):
|
||||
- List the candidate's concrete technical and professional skills: technologies, tools, languages, platforms, and named methodologies.
|
||||
- Write each skill using the resume's own spelling. Every skill you return MUST appear in the resume text.
|
||||
|
|
@ -87,9 +80,7 @@ phone (its own key — extract this separately; copy EVERY digit):
|
|||
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
||||
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits.
|
||||
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
|
||||
- Spaces, hyphens, parentheses, en-dashes, bullets, and non-breaking spaces are allowed; do not delete trailing digits to "clean" the value.
|
||||
- A Phone / Mobile / Cell / WhatsApp / Tel label may sit on the line above the digits — still copy the number.
|
||||
- 03XX-XXXXXXX and +92 3XX XXXXXXX are the same number; return the form written on the resume.
|
||||
- Spaces, hyphens, and parentheses are allowed; do not delete trailing digits to "clean" the value.
|
||||
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
|
||||
|
||||
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
||||
|
|
@ -98,7 +89,6 @@ Example 1 — local 11-digit PK mobile, full LinkedIn:
|
|||
Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
|
||||
JSON:
|
||||
{{
|
||||
"candidate_name": "Ali Khan",
|
||||
"current_employment": "Acme",
|
||||
"education": "BS CS",
|
||||
"current_title": "Engineer",
|
||||
|
|
@ -154,7 +144,6 @@ JSON city must be "Karachi" (one city). Not "Karachi(Malir) Wah Cantt" and not "
|
|||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"candidate_name": "Full Name",
|
||||
"current_employment": "Company Name",
|
||||
"education": "Degree / School",
|
||||
"current_title": "Job Title",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from fastapi import APIRouter,Depends,HTTPException,Query
|
|||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from db_setup import get_session
|
||||
from g_sheet.views import (
|
||||
|
|
@ -26,21 +25,6 @@ def _city_values(city: str | None):
|
|||
return parts or None
|
||||
|
||||
|
||||
def _job_ids(value: str | None):
|
||||
if not value or not str(value).strip():
|
||||
return None
|
||||
out=[]
|
||||
for part in str(value).split(","):
|
||||
text=part.strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
out.append(uuid.UUID(text))
|
||||
except ValueError:
|
||||
continue
|
||||
return out or None
|
||||
|
||||
|
||||
class AppendRowsBody(BaseModel):
|
||||
rows: list[list[str]]
|
||||
|
||||
|
|
@ -225,8 +209,6 @@ async def fetch_form_data(
|
|||
source: str | None = Query(None),
|
||||
assigned: bool | None = Query(None),
|
||||
no_suggestions: bool | None = Query(None),
|
||||
has_suggestions: bool | None = Query(None),
|
||||
job_post_ids: str | None = Query(None),
|
||||
offset: int = Query(0,ge=0),
|
||||
limit: int | None = Query(None,ge=1,le=500),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
|
|
@ -240,7 +222,6 @@ async def fetch_form_data(
|
|||
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
city=_city_values(city),source=(source or "").strip() or None,
|
||||
assigned=assigned,no_suggestions=no_suggestions,
|
||||
has_suggestions=has_suggestions,job_post_ids=_job_ids(job_post_ids),
|
||||
)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -262,7 +243,6 @@ async def fetch_form_data_counts(
|
|||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
assigned: bool | None = Query(None),
|
||||
job_post_ids: str | None = Query(None),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
|
@ -271,7 +251,6 @@ async def fetch_form_data_counts(
|
|||
data=await service.get_counts(
|
||||
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
|
||||
job_post_ids=_job_ids(job_post_ids),
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Index, and_, case, delete, false, func, insert, or_, update
|
||||
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
|
@ -127,108 +127,6 @@ class FormData(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _suggested_contains_any(cls, job_post_ids):
|
||||
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||
|
||||
@classmethod
|
||||
def _has_job_link(cls):
|
||||
return or_(
|
||||
cls.assigned_job_post_id.is_not(None),
|
||||
cls.job_post_id.is_not(None),
|
||||
~cls._no_suggested_jobs(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _matches_any_job(cls, job_post_ids):
|
||||
ids = list(job_post_ids or [])
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(
|
||||
cls.assigned_job_post_id.in_(ids),
|
||||
cls.job_post_id.in_(ids),
|
||||
cls._suggested_contains_any(ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _reapplicant_ids(cls):
|
||||
"""Form rows from emails that have applied more than once.
|
||||
|
||||
Duplicates tab lists flagged duplicates AND every form row from a
|
||||
repeat email, not only the latest.
|
||||
"""
|
||||
ranked = (
|
||||
select(
|
||||
cls.id,
|
||||
cls.reapplied,
|
||||
func.count().over(
|
||||
partition_by=func.lower(func.coalesce(cls.candidate_email, "")),
|
||||
).label("cnt"),
|
||||
)
|
||||
.where(func.coalesce(cls.candidate_email, "") != "")
|
||||
.subquery()
|
||||
)
|
||||
reapplied_n = func.coalesce(func.jsonb_array_length(ranked.c.reapplied), 0)
|
||||
return select(ranked.c.id).where(or_(ranked.c.cnt > 1, reapplied_n > 0))
|
||||
|
||||
@classmethod
|
||||
def _duplicates_tab_filter(cls):
|
||||
return or_(cls.is_duplicate == True, cls.id.in_(cls._reapplicant_ids())) # noqa: E712
|
||||
|
||||
@classmethod
|
||||
def _talent_pool_filters(cls, *, search=None, job_post_ids=None, assignment=None):
|
||||
"""Same WHERE as list_for_talent_pool / count_for_talent_pool."""
|
||||
filters = [cls.manual_upload_candidate_id.is_(None)]
|
||||
if assignment == "assigned":
|
||||
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||
if job_post_ids is not None:
|
||||
filters.append(or_(
|
||||
cls.assigned_job_post_id.in_(list(job_post_ids)),
|
||||
cls.job_post_id.in_(list(job_post_ids)),
|
||||
))
|
||||
elif assignment == "unassigned":
|
||||
filters.append(cls.assigned_job_post_id.is_(None))
|
||||
filters.append(cls.job_post_id.is_(None))
|
||||
filters.append(~cls._no_suggested_jobs())
|
||||
if job_post_ids is not None:
|
||||
filters.append(cls._suggested_contains_any(list(job_post_ids)))
|
||||
elif job_post_ids is not None:
|
||||
filters.append(cls._matches_any_job(list(job_post_ids)))
|
||||
else:
|
||||
filters.append(cls._has_job_link())
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
filters.append(or_(cls.name.ilike(like), cls.candidate_email.ilike(like)))
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None, assignment=None):
|
||||
"""Candidates list: unpromoted form rows with assigned or suggested jobs."""
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
qry = (
|
||||
select(cls)
|
||||
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment))
|
||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.execute(qry)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None, assignment=None):
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return 0
|
||||
qry = select(func.count()).select_from(cls).where(
|
||||
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment)
|
||||
)
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
@ -245,7 +143,7 @@ class FormData(SQLModel, table=True):
|
|||
def _filters(
|
||||
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
|
||||
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
):
|
||||
filters = []
|
||||
if sheet:
|
||||
|
|
@ -253,10 +151,7 @@ class FormData(SQLModel, table=True):
|
|||
if processing_state:
|
||||
filters.append(cls.processing_state == processing_state)
|
||||
if is_duplicate is not None:
|
||||
if is_duplicate:
|
||||
filters.append(cls._duplicates_tab_filter())
|
||||
else:
|
||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||
if has_linkedin is not None:
|
||||
matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS]
|
||||
if has_linkedin:
|
||||
|
|
@ -293,10 +188,6 @@ class FormData(SQLModel, table=True):
|
|||
filters.append(cls.job_post_id.is_(None))
|
||||
if no_suggestions is True:
|
||||
filters.append(cls._no_suggested_jobs())
|
||||
elif has_suggestions is True:
|
||||
filters.append(~cls._no_suggested_jobs())
|
||||
if job_post_ids:
|
||||
filters.append(cls._matches_any_job(list(job_post_ids)))
|
||||
if inbox_filter == "matched":
|
||||
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||
elif inbox_filter == "unassigned":
|
||||
|
|
@ -492,7 +383,7 @@ class FormData(SQLModel, table=True):
|
|||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||
has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
offset=0, limit=None,
|
||||
):
|
||||
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
|
||||
|
|
@ -502,7 +393,6 @@ class FormData(SQLModel, table=True):
|
|||
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
if offset:
|
||||
|
|
@ -663,7 +553,7 @@ class FormData(SQLModel, table=True):
|
|||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=None, has_linkedin=None,
|
||||
has_resume=None, city=None, source=None, assigned=None,
|
||||
no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
|
||||
no_suggestions=None, inbox_filter=None,
|
||||
):
|
||||
statement = select(func.count()).select_from(cls)
|
||||
for clause in cls._filters(
|
||||
|
|
@ -672,7 +562,6 @@ class FormData(SQLModel, table=True):
|
|||
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
result = await session.execute(statement)
|
||||
|
|
@ -682,7 +571,6 @@ class FormData(SQLModel, table=True):
|
|||
async def count_processing(
|
||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
|
||||
job_post_ids=None,
|
||||
):
|
||||
"""Tab badge counts for the Sheet Forms channel.
|
||||
|
||||
|
|
@ -702,14 +590,13 @@ class FormData(SQLModel, table=True):
|
|||
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||
func.coalesce(func.sum(case((cls._duplicates_tab_filter(), 1), else_=0)), 0).label("duplicates"),
|
||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||
func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
|
||||
func.coalesce(func.sum(case((~cls._no_suggested_jobs(), 1), else_=0)), 0).label("suggested"),
|
||||
).select_from(cls)
|
||||
for clause in cls._filters(
|
||||
sheet=sheet, search=search,
|
||||
has_linkedin=has_linkedin, has_resume=has_resume, city=city,
|
||||
source=source, assigned=assigned, job_post_ids=job_post_ids,
|
||||
source=source, assigned=assigned,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
row = (await session.execute(statement)).one()
|
||||
|
|
@ -721,7 +608,6 @@ class FormData(SQLModel, table=True):
|
|||
"rejected": int(row.rejected or 0),
|
||||
"duplicates": int(row.duplicates or 0),
|
||||
"on_hold": int(row.on_hold or 0),
|
||||
"suggested": int(row.suggested or 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -437,7 +437,6 @@ class SheetFormData(Sheet):
|
|||
self,sheet=None,search=None,offset=0,limit=None,
|
||||
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
|
||||
city=None,source=None,assigned=None,no_suggestions=None,
|
||||
has_suggestions=None,job_post_ids=None,
|
||||
):
|
||||
session=self._require_session()
|
||||
rows=await FormData.fetch_form_data(
|
||||
|
|
@ -445,14 +444,12 @@ class SheetFormData(Sheet):
|
|||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
total=await FormData.count_form_data(
|
||||
session,sheet=sheet,search=search,
|
||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
||||
from job.candidate.views import CandidateView
|
||||
|
|
@ -603,11 +600,11 @@ class SheetFormData(Sheet):
|
|||
raise HTTPException(status_code=404,detail="Form data not found")
|
||||
return await self.get_form_data_by_id(record_id)
|
||||
|
||||
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None,job_post_ids=None):
|
||||
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None):
|
||||
return await FormData.count_processing(
|
||||
self._require_session(),sheet=sheet,search=search,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
source=source,assigned=assigned,job_post_ids=job_post_ids,
|
||||
source=source,assigned=assigned,
|
||||
)
|
||||
|
||||
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import hmac
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter,Depends, Query
|
||||
|
|
@ -27,21 +26,6 @@ def _city_values(city: str | None):
|
|||
return parts or None
|
||||
|
||||
|
||||
def _job_ids(raw: str | None):
|
||||
if not raw or not str(raw).strip():
|
||||
return None
|
||||
out=[]
|
||||
for part in str(raw).split(","):
|
||||
text=part.strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
out.append(uuid.UUID(text))
|
||||
except ValueError:
|
||||
continue
|
||||
return out or None
|
||||
|
||||
|
||||
def _apps_payload(items,total,cities=None,sources=None):
|
||||
body={"data":items,"total":total,"status_code":200}
|
||||
if cities is not None:
|
||||
|
|
@ -82,10 +66,6 @@ class AssignJobPostBody(BaseModel):
|
|||
job_post_id: str | None = None
|
||||
|
||||
|
||||
class AssignRecruiterBody(BaseModel):
|
||||
recruiter_id: str | None = None
|
||||
|
||||
|
||||
class ProcessingStateBody(BaseModel):
|
||||
processing_state: str
|
||||
|
||||
|
|
@ -123,11 +103,9 @@ class ReadAllBody(BaseModel):
|
|||
assigned: bool | None = None
|
||||
is_duplicate: bool | None = None
|
||||
no_suggestions: bool | None = None
|
||||
has_suggestions: bool | None = None
|
||||
processing_state: str | None = None
|
||||
city: str | None = None
|
||||
source: str | None = None
|
||||
job_post_ids: str | None = None
|
||||
|
||||
|
||||
class TriageOverrideBody(BaseModel):
|
||||
|
|
@ -279,23 +257,6 @@ async def assign_job_post(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/inbox/{record_id}/assign-recruiter")
|
||||
async def assign_recruiter(
|
||||
record_id: str,
|
||||
payload: AssignRecruiterBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.assign_recruiter(record_id,payload.recruiter_id)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/inbox/{record_id}/read")
|
||||
async def mark_inbox_read(
|
||||
record_id: str,
|
||||
|
|
@ -353,8 +314,6 @@ async def mark_all_inbox_read(
|
|||
processing_state=payload.processing_state,
|
||||
city=_city_values(payload.city),
|
||||
source=(payload.source or "").strip() or None,
|
||||
has_suggestions=payload.has_suggestions,
|
||||
job_post_ids=_job_ids(payload.job_post_ids),
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -387,12 +346,10 @@ async def get_all_applications(
|
|||
assigned: bool | None = Query(default=None),
|
||||
is_duplicate: bool | None = Query(default=None),
|
||||
no_suggestions: bool | None = Query(default=None),
|
||||
has_suggestions: bool | None = Query(default=None),
|
||||
processing_state: str | None = Query(default=None),
|
||||
search: str | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
job_post_ids: str | None = Query(None),
|
||||
city_list: bool = Query(default=False),
|
||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||
top: int | None = Query(None, ge=1, le=500),
|
||||
|
|
@ -404,25 +361,23 @@ async def get_all_applications(
|
|||
service=Email(session=session)
|
||||
city_values=_city_values(city)
|
||||
source_value=(source or "").strip() or None
|
||||
job_ids=_job_ids(job_post_ids)
|
||||
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_ids)
|
||||
cities=await service.list_cities() if city_list else None
|
||||
sources=await service.list_sources() if city_list else None
|
||||
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
if isread==False:
|
||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
|
||||
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
if record_id:
|
||||
item=await service.get_application_by_id(record_id)
|
||||
return _apps_payload(item,1,cities,sources)
|
||||
|
||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
|
||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
|
||||
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
|
||||
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
from shlex import join
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -10,7 +9,7 @@ from dotenv import load_dotenv
|
|||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from role.models import EnumRoles, Roles
|
||||
from sqlalchemy import Column, DateTime, and_, case, false, func, or_, update
|
||||
from sqlalchemy import Column, DateTime, case, false, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -37,20 +36,6 @@ def _now() -> datetime:
|
|||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _years_from_text(value):
|
||||
"""Whole years from ATS int or inbox free-text ('5+', '5 years'). Else None."""
|
||||
if value is None or isinstance(value,bool):
|
||||
return None
|
||||
if isinstance(value,(int,float)):
|
||||
years=int(value)
|
||||
return years if 0<=years<=60 else None
|
||||
digits=re.search(r"\d+",str(value))
|
||||
if not digits:
|
||||
return None
|
||||
years=int(digits.group())
|
||||
return years if 0<=years<=60 else None
|
||||
|
||||
|
||||
class Inbox(SQLModel, table=True):
|
||||
__tablename__ = "inbox"
|
||||
|
||||
|
|
@ -89,7 +74,7 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
|
||||
@classmethod
|
||||
async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0,search=None):
|
||||
async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0):
|
||||
try:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -139,9 +124,6 @@ class Inbox(SQLModel, table=True):
|
|||
qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids))
|
||||
elif job_post_id:
|
||||
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||
if search and str(search).strip():
|
||||
like=f"%{str(search).strip()}%"
|
||||
qry=qry.where(or_(Users.name.ilike(like),Users.email.ilike(like)))
|
||||
if limit is not None:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
result=await session.execute(qry)
|
||||
|
|
@ -279,17 +261,11 @@ class Inbox(SQLModel, table=True):
|
|||
Inbox_Messages.current_employment.label("current_company"),
|
||||
Inbox_Messages.current_title,
|
||||
Inbox_Messages.candidate_education.label("education"),
|
||||
Inbox_Messages.city,
|
||||
Inbox_Messages.experience.label("inbox_experience"),
|
||||
cls.message_id.label("message_id"),
|
||||
Inbox_Messages.file_name,
|
||||
Inbox_Messages.file_path,
|
||||
Inbox_Messages.ats_score,
|
||||
Inbox_Messages.ats_band,
|
||||
Inbox_Messages.assigned_job_post_id,
|
||||
Inbox_Messages.suggested_job_post_ids,
|
||||
cls.created_at,
|
||||
JobPosts.id.label("last_job_post_id"),
|
||||
JobPosts.title.label("last_job_title"),
|
||||
Candidates.matched_keywords,
|
||||
Candidates.years_experience,
|
||||
|
|
@ -322,18 +298,13 @@ class Inbox(SQLModel, table=True):
|
|||
"current_company":row["current_company"] or None,
|
||||
"current_title":row["current_title"] or None,
|
||||
"education":row["education"] or None,
|
||||
"city":row["city"] or None,
|
||||
"file_name":row["file_name"] or None,
|
||||
"file_path":row["file_path"] or None,
|
||||
"ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None,
|
||||
"recommendation":row["ats_band"] or None,
|
||||
"message_id":str(row["message_id"]) if row["message_id"] else None,
|
||||
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||
"suggested_job_post_ids":list(row["suggested_job_post_ids"] or []),
|
||||
"last_job_post_id":str(row["last_job_post_id"]) if row["last_job_post_id"] else None,
|
||||
"last_job_title":row["last_job_title"] or None,
|
||||
"matched_keywords":list(row["matched_keywords"] or []),
|
||||
"years_experience":row["years_experience"] if row["years_experience"] is not None else _years_from_text(row["inbox_experience"]),
|
||||
"years_experience":row["years_experience"],
|
||||
"bank_expires_at":None,
|
||||
"created_at":row["created_at"],
|
||||
})
|
||||
|
|
@ -361,7 +332,7 @@ class Inbox(SQLModel, table=True):
|
|||
return out
|
||||
|
||||
@classmethod
|
||||
async def count_by_status(cls,session:AsyncSession,job_post_id=None,search=None):
|
||||
async def count_by_status(cls,session:AsyncSession,job_post_id=None):
|
||||
try:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -378,9 +349,6 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
if job_post_id:
|
||||
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||
if search and str(search).strip():
|
||||
like=f"%{str(search).strip()}%"
|
||||
qry=qry.where(or_(Users.name.ilike(like),Users.email.ilike(like)))
|
||||
result=await session.execute(qry)
|
||||
counts={}
|
||||
for status,n in result.all():
|
||||
|
|
@ -398,37 +366,7 @@ class Inbox(SQLModel, table=True):
|
|||
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
||||
|
||||
@classmethod
|
||||
def _with_job_link(cls, qry, job_post_ids, assignment=None):
|
||||
"""List mode: keep only applications with an assigned post or suggestions.
|
||||
|
||||
`job_post_ids is None` is the unscoped (admin) list — still require a
|
||||
link so unassigned inbox mail never appears on Candidates. A UUID list
|
||||
is assigned IN those ids OR suggested_job_post_ids containing any of
|
||||
them (the `assigned_job_post_id` query param is that one-element list).
|
||||
|
||||
`assignment` is assigned | unassigned | None. Assigned means a real
|
||||
assigned_job_post_id. Unassigned means suggestions only — no assigned post.
|
||||
"""
|
||||
qry = qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
if assignment == "assigned":
|
||||
qry = qry.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||
if job_post_ids is not None:
|
||||
return qry.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
return qry
|
||||
if assignment == "unassigned":
|
||||
qry = qry.where(
|
||||
Inbox_Messages.assigned_job_post_id.is_(None),
|
||||
~Inbox_Messages._no_suggested_jobs(),
|
||||
)
|
||||
if job_post_ids is not None:
|
||||
return qry.where(Inbox_Messages._suggested_contains_any(list(job_post_ids)))
|
||||
return qry
|
||||
if job_post_ids is not None:
|
||||
return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids)))
|
||||
return qry.where(Inbox_Messages._has_job_link())
|
||||
|
||||
@classmethod
|
||||
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None,assignment=None):
|
||||
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None):
|
||||
try:
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
|
|
@ -450,8 +388,11 @@ class Inbox(SQLModel, table=True):
|
|||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
# Most-recent-first is the list contract; id breaks ties so a page
|
||||
# boundary can't drop or repeat a row when created_at collides.
|
||||
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
|
|
@ -465,7 +406,7 @@ class Inbox(SQLModel, table=True):
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None,assignment=None):
|
||||
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None):
|
||||
"""Result-set size for the same predicate get_candidate_profile pages over."""
|
||||
try:
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
|
|
@ -481,8 +422,11 @@ class Inbox(SQLModel, table=True):
|
|||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
|
|
@ -531,7 +475,7 @@ class Inbox(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
|
|
@ -1082,56 +1026,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
else_=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _suggested_contains_any(cls, job_post_ids):
|
||||
"""JSONB @> '["uuid"]' for any id — suggested_job_post_ids stores strings."""
|
||||
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||
|
||||
@classmethod
|
||||
def _has_job_link(cls):
|
||||
return or_(cls.assigned_job_post_id.is_not(None), ~cls._no_suggested_jobs())
|
||||
|
||||
@classmethod
|
||||
def _matches_any_job(cls, job_post_ids):
|
||||
ids = list(job_post_ids or [])
|
||||
if not ids:
|
||||
return false()
|
||||
return or_(
|
||||
cls.assigned_job_post_id.in_(ids),
|
||||
cls._suggested_contains_any(ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _latest_reapplicant_ids(cls):
|
||||
"""Newest inbox application per sender who has applied more than once.
|
||||
|
||||
Duplicates tab lists flagged duplicates AND this latest row — older
|
||||
reapplicant mail stays on All Applications.
|
||||
"""
|
||||
ranked = (
|
||||
select(
|
||||
cls.id,
|
||||
func.row_number().over(
|
||||
partition_by=func.lower(cls.message_from),
|
||||
order_by=(cls.created_at.desc(), cls.id.desc()),
|
||||
).label("rn"),
|
||||
func.count().over(partition_by=func.lower(cls.message_from)).label("cnt"),
|
||||
)
|
||||
.where(cls.attachment == True) # noqa: E712
|
||||
.subquery()
|
||||
)
|
||||
return select(ranked.c.id).where(
|
||||
ranked.c.rn == 1,
|
||||
ranked.c.cnt > 1,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _duplicates_tab_filter(cls):
|
||||
return or_(cls.is_duplicate == True, cls.id.in_(cls._latest_reapplicant_ids())) # noqa: E712
|
||||
|
||||
@staticmethod
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
@ -1155,8 +1049,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
city=None,
|
||||
source: str | None=None,
|
||||
inbox_filter: str | None=None,
|
||||
has_suggestions: bool | None=None,
|
||||
job_post_ids=None,
|
||||
):
|
||||
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
|
||||
|
||||
|
|
@ -1176,15 +1068,11 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
if isread==False:
|
||||
statement = statement.where(cls.message_read==False)
|
||||
if is_duplicate is True:
|
||||
statement = statement.where(cls._duplicates_tab_filter())
|
||||
statement = statement.where(cls.is_duplicate==True) # noqa: E712
|
||||
elif is_duplicate is False:
|
||||
statement = statement.where(cls.is_duplicate==False) # noqa: E712
|
||||
if no_suggestions is True:
|
||||
statement = statement.where(cls._no_suggested_jobs())
|
||||
elif has_suggestions is True:
|
||||
statement = statement.where(~cls._no_suggested_jobs())
|
||||
if job_post_ids:
|
||||
statement = statement.where(cls._matches_any_job(job_post_ids))
|
||||
if processing_state:
|
||||
statement = statement.where(cls.processing_state == processing_state)
|
||||
cities = [c.strip() for c in (city or []) if (c or "").strip()]
|
||||
|
|
@ -1224,13 +1112,12 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def get_inbox_messages(
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False, has_suggestions: bool | None=None, job_post_ids=None,
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False
|
||||
):
|
||||
statement = cls._apply_filters(
|
||||
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city, source,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
)
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
|
|
@ -1293,23 +1180,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_resume_text(cls, session: AsyncSession, record_id, text, *, only_if_empty=True):
|
||||
"""Keep extracted CV text on the inbox row so Inbox / ATS rerun can fetch it."""
|
||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
value = (text or "").strip() or None
|
||||
if not value:
|
||||
return row
|
||||
if only_if_empty and (row.resume_text or "").strip():
|
||||
return row
|
||||
row.resume_text = value
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||
|
|
@ -1340,24 +1210,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_recruiter(cls, session: AsyncSession, record_id, recruiter_id):
|
||||
"""Set or clear recruiter_id on one inbox row."""
|
||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
if recruiter_id is None:
|
||||
row.recruiter_id = None
|
||||
else:
|
||||
try:
|
||||
row.recruiter_id = uuid.UUID(str(recruiter_id))
|
||||
except ValueError:
|
||||
return None
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]:
|
||||
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query.
|
||||
|
|
@ -1376,12 +1228,11 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||
|
||||
@classmethod
|
||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, has_suggestions: bool | None=None, job_post_ids=None):
|
||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None):
|
||||
statement = cls._apply_filters(
|
||||
select(func.count()).select_from(cls),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city, source,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
|
@ -1500,8 +1351,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
no_suggestions: bool | None=None,
|
||||
city=None,
|
||||
source: str | None=None,
|
||||
has_suggestions: bool | None=None,
|
||||
job_post_ids=None,
|
||||
) -> int:
|
||||
"""Mark every row matching a list filter. Returns rows actually CHANGED.
|
||||
|
||||
|
|
@ -1513,7 +1362,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
statement=cls._apply_filters(
|
||||
update(cls),search,isread,application_status,assigned,is_duplicate,
|
||||
no_suggestions,processing_state,city,source,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
statement=statement.where(cls.message_read!=bool(read))
|
||||
result=await session.execute(
|
||||
|
|
@ -1530,9 +1378,8 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||
func.coalesce(func.sum(case((cls._duplicates_tab_filter(), 1), else_=0)), 0).label("duplicates"),
|
||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||
func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
|
||||
func.coalesce(func.sum(case((~cls._no_suggested_jobs(), 1), else_=0)), 0).label("suggested"),
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
|
||||
).where(cls.attachment == True) # noqa: E712
|
||||
|
|
@ -1545,7 +1392,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
"rejected": int(row.rejected or 0),
|
||||
"duplicates": int(row.duplicates or 0),
|
||||
"on_hold": int(row.on_hold or 0),
|
||||
"suggested": int(row.suggested or 0),
|
||||
"assigned": int(row.assigned or 0),
|
||||
"unassigned": int(row.unassigned or 0),
|
||||
}
|
||||
|
|
@ -1624,7 +1470,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
|
|
@ -1691,7 +1537,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
statement = statement.group_by(cls.application_status)
|
||||
result = await session.execute(statement)
|
||||
|
|
@ -1735,7 +1581,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
statement = statement.group_by(cls.assigned_job_post_id)
|
||||
result = await session.execute(statement)
|
||||
|
|
|
|||
|
|
@ -2,24 +2,6 @@ from pathlib import Path
|
|||
|
||||
from inbox.models import Inbox_Message_Triage, Inbox_Messages
|
||||
|
||||
_PHONE_PLACEHOLDER = "xxx-xxx-xxxx"
|
||||
|
||||
|
||||
def _stored_phone(value):
|
||||
text = (value or "").strip()
|
||||
if not text or text.lower() == _PHONE_PLACEHOLDER:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _stored_experience(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
years = int(value)
|
||||
return str(years)
|
||||
return str(value).strip()
|
||||
|
||||
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
|
||||
_RESUME_STATUS = {
|
||||
"processing": "Parsing",
|
||||
|
|
@ -103,14 +85,6 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
|||
"ats_score": message.ats_score,
|
||||
"ats_band": message.ats_band or None,
|
||||
"professional_summary": message.professional_summary or None,
|
||||
"phone": _stored_phone(message.candidate_phone_number),
|
||||
"experience": _stored_experience(message.experience),
|
||||
"current_employment": message.current_employment or "",
|
||||
"current_title": message.current_title or "",
|
||||
"city": message.city or None,
|
||||
"education": message.candidate_education or "",
|
||||
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
|
||||
"recruiter": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -180,14 +154,12 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
|
|||
"ats_score": message.ats_score,
|
||||
"ats_band": message.ats_band or None,
|
||||
"professional_summary": message.professional_summary or None,
|
||||
"phone": _stored_phone(message.candidate_phone_number),
|
||||
"experience": _stored_experience(message.experience),
|
||||
"phone": message.candidate_phone_number,
|
||||
"experience": message.experience or "",
|
||||
"current_employment": message.current_employment or "",
|
||||
"current_title": message.current_title or "",
|
||||
"city": message.city or None,
|
||||
"education": message.candidate_education or "",
|
||||
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
|
||||
"recruiter": None,
|
||||
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
|
||||
"duplicate": message.is_duplicate,
|
||||
"processing_state": message.processing_state,
|
||||
"source_channel_id": message.source_channel_id,
|
||||
|
|
|
|||
|
|
@ -213,8 +213,9 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
|
||||
result=await run_agent(subject=subject,resume_text=text,job_posts=job_posts)
|
||||
status=result.get("status") or "failed"
|
||||
# Extract the profile even when matching finds no job — On-Hold / unassigned
|
||||
# CVs still need name, title, years, and phone on every screen.
|
||||
if status=="failed":
|
||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||
|
||||
fields=await run_employment_agent(
|
||||
resume_text=text if not body else f"{text}\n\n{body}",
|
||||
)
|
||||
|
|
@ -224,39 +225,14 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
|
|||
linkedin_url=fields["linkedin_url"]
|
||||
phone=fields["phone"]
|
||||
city=fields.get("city") or None
|
||||
years=fields.get("years_experience")
|
||||
experience=(result.get("experience") or "").strip()
|
||||
if not experience and years is not None:
|
||||
experience=str(int(years)) if isinstance(years,(int,float)) and not isinstance(years,bool) else str(years)
|
||||
|
||||
if status=="failed":
|
||||
async with session_scope() as session:
|
||||
await Inbox_Messages.set_match_result(
|
||||
session,
|
||||
record_id,
|
||||
resume_text=text,
|
||||
experience=experience,
|
||||
candidate_phone_number=phone if phone else "",
|
||||
current_employment=current_employment,
|
||||
current_title=current_title,
|
||||
candidate_education=education,
|
||||
linkedin_url=linkedin_url,
|
||||
city=city,
|
||||
suggested_job_post_ids=[],
|
||||
summary=result.get("summary") or "",
|
||||
reasoning=result.get("reasoning") or "",
|
||||
status="failed",
|
||||
error=result.get("error") or "agent returned failed status",
|
||||
)
|
||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||
|
||||
async with session_scope() as session:
|
||||
await Inbox_Messages.set_match_result(
|
||||
session,
|
||||
record_id,
|
||||
resume_text=text,
|
||||
experience=experience,
|
||||
candidate_phone_number=phone if phone else "",
|
||||
experience=result.get("experience") or "",
|
||||
candidate_phone_number=phone,
|
||||
current_employment=current_employment,
|
||||
current_title=current_title,
|
||||
candidate_education=education,
|
||||
|
|
|
|||
|
|
@ -259,14 +259,13 @@ class Email:
|
|||
items=await self._attach_job_posts([item])
|
||||
return await cv.attach_application_history(items[0])
|
||||
|
||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids,light=True)
|
||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
elif isread==False:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
else:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
|
||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
||||
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||
items=await self._attach_job_posts(items)
|
||||
|
|
@ -311,19 +310,6 @@ class Email:
|
|||
suggested.append(dict(payload))
|
||||
item["suggested_job_posts"]=suggested
|
||||
items=await self._paint_inbox_ats(items)
|
||||
items=await self._attach_recruiters(items)
|
||||
return items
|
||||
|
||||
async def _attach_recruiters(self,items):
|
||||
"""Resolve recruiter_id → display name. Serializer leaves recruiter None."""
|
||||
ids=[item.get("recruiter_id") for item in items if item.get("recruiter_id")]
|
||||
names={}
|
||||
if ids:
|
||||
from users.models import Users
|
||||
names=await Users.names_by_ids(self.session,ids)
|
||||
for item in items:
|
||||
rid=item.get("recruiter_id")
|
||||
item["recruiter"]=names.get(str(rid)) if rid else None
|
||||
return items
|
||||
|
||||
async def _paint_inbox_ats(self,items):
|
||||
|
|
@ -781,14 +767,13 @@ class Email:
|
|||
results.append({"email":email,"sent":False})
|
||||
return results
|
||||
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids)
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
|
||||
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
elif isread==False:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
else:
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
|
||||
|
||||
async def list_cities(self):
|
||||
"""Proper city names for the Inbox filter — DISTINCT of the stored city column."""
|
||||
|
|
@ -835,23 +820,6 @@ class Email:
|
|||
logger.warning("could not queue ats score for %s: %s",record_id,exc)
|
||||
return await self.get_inbox_message_by_id(record_id)
|
||||
|
||||
async def assign_recruiter(self,record_id,recruiter_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Message not found")
|
||||
if recruiter_id is not None:
|
||||
from role.models import EnumRoles
|
||||
from users.models import Users
|
||||
user=await Users.get_user_by_id(self.session,recruiter_id)
|
||||
role=getattr(user,"role",None) if user else None
|
||||
role_name=getattr(role,"role_name",None)
|
||||
if user is None or role_name != EnumRoles.RECRUITER.value:
|
||||
raise HTTPException(status_code=422,detail="recruiter_id must be an active recruiter")
|
||||
updated=await Inbox_Messages.set_recruiter(self.session,record_id,recruiter_id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404,detail="Message not found")
|
||||
return await self.get_inbox_message_by_id(record_id)
|
||||
|
||||
async def mark_read(self,record_id,read=True):
|
||||
message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
|
||||
if not message:
|
||||
|
|
@ -881,7 +849,7 @@ class Email:
|
|||
async def set_read_all(self,read,search=None,isread:bool=True,
|
||||
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
|
||||
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
|
||||
city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||
city=None,source=None):
|
||||
"""Mark every row the SAME filter set would have listed.
|
||||
|
||||
The filter arguments are the caller's current view, not a free-form query: the
|
||||
|
|
@ -893,7 +861,6 @@ class Email:
|
|||
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
||||
no_suggestions=no_suggestions,processing_state=processing_state,
|
||||
city=city,source=source,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
|
||||
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ class JobUpdate(BaseModel):
|
|||
experience_max: int | None = None
|
||||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
|
|
@ -346,7 +345,7 @@ async def cv_bank_upload(
|
|||
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
|
||||
session,
|
||||
candidate_email=detected or "",
|
||||
candidate_name=(profile.get("candidate_name") or "").strip(),
|
||||
candidate_name="",
|
||||
full_text=text,
|
||||
file_name=original,
|
||||
created_by=current_user.get("id"),
|
||||
|
|
@ -731,12 +730,6 @@ class InboxScoreRequest(BaseModel):
|
|||
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
|
||||
|
||||
|
||||
class AtsRerunBody(BaseModel):
|
||||
user_id: UUID | None = None
|
||||
inbox_message_id: UUID | None = None
|
||||
manual_upload_candidate_id: UUID | None = None
|
||||
|
||||
|
||||
@router.post("/candidate/score")
|
||||
async def score_candidates(
|
||||
job_id: str = Form(...),
|
||||
|
|
@ -773,28 +766,6 @@ async def score_inbox_candidates(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/candidate/ats-rerun")
|
||||
async def rerun_candidate_ats(
|
||||
payload: AtsRerunBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Re-score one candidate from stored professional_summary / inbox extract."""
|
||||
try:
|
||||
service=CandidateScoring(session=session)
|
||||
data=await service.rerun_ats(
|
||||
current_user,
|
||||
user_id=payload.user_id,
|
||||
inbox_message_id=payload.inbox_message_id,
|
||||
manual_upload_candidate_id=payload.manual_upload_candidate_id,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/scored/fetch")
|
||||
async def fetch_scored_candidates(
|
||||
job_id: str = Query(None),
|
||||
|
|
@ -1053,8 +1024,7 @@ async def fetch_manager_candidates(
|
|||
async def fetch_candidate(
|
||||
user_id:str=Query(None),
|
||||
limit:int=Query(10,ge=1,le=100),
|
||||
assigned_job_post_id:Optional[str]=Query(None),
|
||||
assignment:Optional[str]=Query(None),
|
||||
assigned_job_post_id:UUID=Query(None),
|
||||
offset:int=Query(0,ge=0),
|
||||
search:str=Query(None),
|
||||
created_by:Optional[bool]=Query(False),
|
||||
|
|
@ -1062,17 +1032,14 @@ async def fetch_candidate(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
assignment_value=(assignment or "").strip().lower() or None
|
||||
if assignment_value and assignment_value not in ("assigned","unassigned"):
|
||||
raise HTTPException(status_code=422,detail="assignment must be assigned or unassigned")
|
||||
service=CandidateView(session=session)
|
||||
data=await service.get_candidate(
|
||||
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
|
||||
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
total=await service.count_candidates(
|
||||
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
|
||||
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
) if isinstance(data,list) else 1
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -1369,7 +1336,6 @@ async def change_candidate_stage(
|
|||
@router.get("/pipeline/candidates/fetch")
|
||||
async def fetch_pipeline_candidates(
|
||||
job_post_id:Optional[uuid.UUID]=Query(None),
|
||||
search:Optional[str]=Query(None),
|
||||
limit:int=Query(10,ge=1,le=1000),
|
||||
offset:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||
|
|
@ -1377,7 +1343,7 @@ async def fetch_pipeline_candidates(
|
|||
):
|
||||
try:
|
||||
service=Pipeline(session=session)
|
||||
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset)
|
||||
return JSONResponse(content={**result,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -82,50 +82,6 @@ class JobAssignments(SQLModel, table=True):
|
|||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
@classmethod
|
||||
async def sync_open(cls, session: AsyncSession, job_post_id, assignment_role, user_ids, assigned_by):
|
||||
"""Make open intervals for this role match user_ids (order preserved)."""
|
||||
uid = cls._as_uuid(job_post_id)
|
||||
by_uid = cls._as_uuid(assigned_by)
|
||||
if uid is None or not assignment_role or by_uid is None:
|
||||
return 0
|
||||
wanted = []
|
||||
seen = set()
|
||||
for raw in user_ids or []:
|
||||
user_uid = cls._as_uuid(raw)
|
||||
if user_uid is None:
|
||||
continue
|
||||
key = str(user_uid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
wanted.append(user_uid)
|
||||
current = await cls.fetch_by_job(
|
||||
session, uid, current_only=True, assignment_role=assignment_role,
|
||||
)
|
||||
current_map = {str(r.user_id): r for r in current}
|
||||
now = _now()
|
||||
wanted_set = {str(u) for u in wanted}
|
||||
changed = False
|
||||
for key, row in current_map.items():
|
||||
if key not in wanted_set:
|
||||
row.valid_to = now
|
||||
session.add(row)
|
||||
changed = True
|
||||
for user_uid in wanted:
|
||||
if str(user_uid) in current_map:
|
||||
continue
|
||||
session.add(cls(
|
||||
job_post_id=uid,
|
||||
user_id=user_uid,
|
||||
assignment_role=assignment_role,
|
||||
assigned_by=by_uid,
|
||||
))
|
||||
changed = True
|
||||
if changed:
|
||||
await session.commit()
|
||||
return len(wanted)
|
||||
|
||||
@classmethod
|
||||
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
|
|
|
|||
|
|
@ -80,16 +80,6 @@ class Assignment:
|
|||
"assigned_by":by_uid,
|
||||
})
|
||||
|
||||
async def record_job_recruiters(self,job_post_id,user_ids,assigned_by):
|
||||
"""Keep open primary_recruiter intervals in sync with the JSON list."""
|
||||
job_uid=JobAssignments._as_uuid(job_post_id)
|
||||
by_uid=JobAssignments._as_uuid(assigned_by)
|
||||
if not job_uid or not by_uid:
|
||||
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
|
||||
return await JobAssignments.sync_open(
|
||||
self.session,job_uid,"primary_recruiter",user_ids,by_uid,
|
||||
)
|
||||
|
||||
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
|
||||
if not job_post_id:
|
||||
raise HTTPException(status_code=400,detail="job_post_id is required")
|
||||
|
|
@ -115,24 +105,18 @@ class Assignment:
|
|||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||
column=JOB_OWNER_COLUMN[role]
|
||||
patch={column:user_id}
|
||||
if role=="primary_recruiter":
|
||||
patch["current_recruiter_ids"]=[str(user_id)]
|
||||
updated=await JobPosts.update_job_post(self.session,job_post_id,patch)
|
||||
updated=await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
|
||||
if updated:
|
||||
try:
|
||||
from notifications.views import notify_job_assignment
|
||||
label="hiring manager" if role=="hiring_manager" else "recruiter"
|
||||
previous=(
|
||||
[job.hiring_manager_id]
|
||||
if role=="hiring_manager"
|
||||
else JobPosts.recruiter_ids_of(job)
|
||||
)
|
||||
await notify_job_assignment(
|
||||
self.session,updated,
|
||||
role_label=label,
|
||||
actor_id=assigned_by,
|
||||
previous_ids=previous,
|
||||
previous_ids=[
|
||||
job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id
|
||||
],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
|
|
|
|||
|
|
@ -197,25 +197,19 @@ async def _notify_owner(job_post_id: str, count: int) -> None:
|
|||
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||
if job is None:
|
||||
return
|
||||
ids = JobPosts.recruiter_ids_of(job)
|
||||
if not ids:
|
||||
created = getattr(job, "created_by", None)
|
||||
if created:
|
||||
ids = [str(created)]
|
||||
if not ids:
|
||||
raw = getattr(job, "current_recruiter_id", None) or getattr(job, "created_by", None)
|
||||
if not raw:
|
||||
return
|
||||
body = (
|
||||
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
||||
f"{job.title}. Open the CV Bank to review them."
|
||||
)
|
||||
for raw in ids:
|
||||
await Notifications.insert_notification(session, {
|
||||
"user_id": _uuid.UUID(str(raw)),
|
||||
"kind": "application",
|
||||
"title": "CVs in the bank match this job",
|
||||
"body": body,
|
||||
"link_path": f"/cvbank?job={job_post_id}",
|
||||
"job_post_id": job.id,
|
||||
})
|
||||
await Notifications.insert_notification(session, {
|
||||
"user_id": _uuid.UUID(str(raw)),
|
||||
"kind": "application",
|
||||
"title": "CVs in the bank match this job",
|
||||
"body": (
|
||||
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
||||
f"{job.title}. Open the CV Bank to review them."
|
||||
),
|
||||
"link_path": f"/cvbank?job={job_post_id}",
|
||||
"job_post_id": job.id,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@classmethod
|
||||
async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0, search=None):
|
||||
async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0):
|
||||
try:
|
||||
from inbox.models import AtsResults
|
||||
from users.models import Users
|
||||
|
|
@ -133,14 +133,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
qry=qry.where(cls.job_post_id.in_(ids))
|
||||
elif job_post_id:
|
||||
qry=qry.where(cls.job_post_id==job_post_id)
|
||||
if search and str(search).strip():
|
||||
like=f"%{str(search).strip()}%"
|
||||
qry=qry.where(or_(
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
))
|
||||
if limit is not None:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
result=await session.execute(qry)
|
||||
|
|
@ -182,7 +174,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def count_by_status(cls, session: AsyncSession, job_post_id=None, search=None):
|
||||
async def count_by_status(cls, session: AsyncSession, job_post_id=None):
|
||||
try:
|
||||
from users.models import Users
|
||||
from job.job_post.models import JobPosts
|
||||
|
|
@ -195,14 +187,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
)
|
||||
if job_post_id:
|
||||
qry=qry.where(cls.job_post_id==job_post_id)
|
||||
if search and str(search).strip():
|
||||
like=f"%{str(search).strip()}%"
|
||||
qry=qry.where(or_(
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
))
|
||||
result=await session.execute(qry)
|
||||
counts={}
|
||||
for status,n in result.all():
|
||||
|
|
@ -241,7 +225,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
except (TypeError,ValueError):
|
||||
rid=None
|
||||
if rid is not None:
|
||||
qry=qry.where(JobPosts.has_recruiter(rid))
|
||||
qry=qry.where(JobPosts.current_recruiter_id==rid)
|
||||
qry=qry.group_by(cls.status)
|
||||
result=await session.execute(qry)
|
||||
counts={}
|
||||
|
|
@ -274,7 +258,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
qry=qry.where(JobPosts.department==department)
|
||||
rid=cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
qry=qry.where(JobPosts.has_recruiter(rid))
|
||||
qry=qry.where(JobPosts.current_recruiter_id==rid)
|
||||
qry=qry.group_by(cls.job_post_id)
|
||||
result=await session.execute(qry)
|
||||
return {str(job_id):int(n or 0) for job_id,n in result.all()}
|
||||
|
|
@ -395,24 +379,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def set_full_text(cls, session: AsyncSession, record_id, text, *, only_if_empty=True):
|
||||
"""Keep extracted CV text on the bank/manual row (Inbox uses resume_text)."""
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
value = (text or "").strip()
|
||||
if not value:
|
||||
return row
|
||||
if only_if_empty and (row.full_text or "").strip():
|
||||
return row
|
||||
row.full_text = value
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_professional_summary(cls, session: AsyncSession, record_id, summary):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
|
|
@ -518,26 +484,9 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
await session.commit()
|
||||
return updated
|
||||
|
||||
@classmethod
|
||||
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
|
||||
filters = [cls.user_id.is_not(None), cls.job_post_id.is_not(None)]
|
||||
if job_post_ids is not None:
|
||||
filters.append(cls.job_post_id.in_(list(job_post_ids)))
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
)
|
||||
)
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||
"""Newest applications with a user + assigned job for Candidates / Talent Pool."""
|
||||
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
||||
from users.models import Users
|
||||
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
|
|
@ -545,29 +494,25 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
statement = (
|
||||
select(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
||||
.order_by(cls.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
if job_post_ids is not None:
|
||||
statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
or_(
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
)
|
||||
)
|
||||
statement = statement.limit(limit).offset(offset)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
|
||||
from users.models import Users
|
||||
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return 0
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
||||
|
|
@ -701,7 +646,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
education=(extracted.get("education") or "").strip(),
|
||||
skills=extracted.get("skills") or [],
|
||||
years_experience=extracted.get("years_experience"),
|
||||
experience="" if extracted.get("years_experience") is None else str(extracted.get("years_experience")),
|
||||
bank_reason=(bank_reason or "").strip(),
|
||||
bank_expires_at=expires_at,
|
||||
apply_via="cv_bank",
|
||||
|
|
@ -729,13 +673,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
||||
"""Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job.
|
||||
|
||||
Run ATS assigns job_post_id so the row joins Pipeline like any other
|
||||
application. The bank still lists them so the ATS score is visible on
|
||||
this screen. Matching remains the assign queue for unassigned rows.
|
||||
"""
|
||||
bank = (cls.apply_via == "cv_bank",)
|
||||
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
|
||||
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(cls).where(*bank)
|
||||
|
|
@ -781,11 +720,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
row = await session.get(cls, cls._as_uuid(record_id))
|
||||
if row is None:
|
||||
return None
|
||||
extracted_name = (profile.get("candidate_name") or "").strip()
|
||||
current_name = (row.candidate_name or "").strip()
|
||||
email = (row.candidate_email or "").strip()
|
||||
if extracted_name and (not current_name or current_name.lower() == email.lower()):
|
||||
row.candidate_name = extracted_name
|
||||
if not (row.current_company or "").strip():
|
||||
row.current_company = (profile.get("current_company") or "").strip()
|
||||
if not (row.current_position or "").strip():
|
||||
|
|
@ -798,8 +732,6 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
row.skills = profile.get("skills") or []
|
||||
if row.years_experience is None:
|
||||
row.years_experience = profile.get("years_experience")
|
||||
if not (row.experience or "").strip() and row.years_experience is not None:
|
||||
row.experience = str(row.years_experience)
|
||||
if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"):
|
||||
row.linkedin_url = profile["linkedin_url"]
|
||||
row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG
|
||||
|
|
@ -1057,12 +989,6 @@ class Candidates(SQLModel, table=True):
|
|||
professional_summary: str | None = Field(default=None)
|
||||
# Public LinkedIn URL extracted from the scored CV. Fetch reads this column.
|
||||
linkedin_url: str | None = Field(default=None)
|
||||
# Application that owns the extracted CV text. Inbox email → inbox_messages.resume_text;
|
||||
# Add Candidate / CV bank → manual_upload_candidate.full_text. Migration 036.
|
||||
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id", index=True)
|
||||
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||
default=None, foreign_key="manual_upload_candidate.id", index=True
|
||||
)
|
||||
|
||||
status: str # "completed" | "failed"
|
||||
error_code: str | None = Field(default=None)
|
||||
|
|
@ -1164,8 +1090,8 @@ class Candidates(SQLModel, table=True):
|
|||
"source": "ats",
|
||||
"email": email,
|
||||
"inbox_id": None,
|
||||
"message_id": str(rec.inbox_message_id) if getattr(rec, "inbox_message_id", None) else None,
|
||||
"manual_upload_candidate_id": str(rec.manual_upload_candidate_id) if getattr(rec, "manual_upload_candidate_id", None) else None,
|
||||
"message_id": None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": None,
|
||||
"candidate_id": str(rec.id),
|
||||
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||
|
|
@ -1175,38 +1101,6 @@ class Candidates(SQLModel, table=True):
|
|||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def latest_completed_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Newest completed ATS score per email, with the job it ran against.
|
||||
|
||||
CV Bank speculative rows have no candidates FK; email is the join the
|
||||
scorer already writes (score_bank → upsert_candidate).
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
select(cls, JobPosts.title)
|
||||
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
|
||||
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||
.where(cls.status == "completed")
|
||||
.where(cls.match_score.is_not(None))
|
||||
.order_by(cls.updated_at.desc(), cls.created_at.desc())
|
||||
)
|
||||
out = {}
|
||||
for rec, title in result.all():
|
||||
email = (rec.candidate_email or "").strip().lower()
|
||||
if not email or email in out:
|
||||
continue
|
||||
out[email] = {
|
||||
"match_score": int(rec.match_score) if rec.match_score is not None else None,
|
||||
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||
"job_title": title or rec.job_title or None,
|
||||
}
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||
existing = None
|
||||
|
|
@ -1358,7 +1252,7 @@ class Interviews(SQLModel, table=True):
|
|||
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, JobPosts.id == job_id)
|
||||
.where(JobPosts.has_recruiter(rid))
|
||||
.where(JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1832,7 +1726,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
value = result.scalar_one()
|
||||
|
|
@ -1856,7 +1750,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
return statement
|
||||
|
||||
|
|
|
|||
|
|
@ -101,20 +101,12 @@ def build_job_description(job) -> str:
|
|||
|
||||
|
||||
def candidate_base_fields(source):
|
||||
fields = {
|
||||
return {
|
||||
"filename": source["safe_name"],
|
||||
"file_path": source["file_path"],
|
||||
"content_sha256": source["sha256"],
|
||||
"candidate_email": source.get("candidate_email"),
|
||||
}
|
||||
# Omit empty FKs so an upsert cannot wipe a link that this source does not know.
|
||||
inbox_mid = source.get("inbox_message_id")
|
||||
if inbox_mid:
|
||||
fields["inbox_message_id"] = inbox_mid
|
||||
manual_id = source.get("manual_upload_candidate_id")
|
||||
if manual_id:
|
||||
fields["manual_upload_candidate_id"] = manual_id
|
||||
return fields
|
||||
|
||||
|
||||
def candidate_failed_fields(source, code, message):
|
||||
|
|
|
|||
|
|
@ -13,18 +13,6 @@ from job.activity.serializers import serialize_activity
|
|||
from job.feedback.serializers import serialize_feedback
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
|
||||
def _id_str(value):
|
||||
if value in (None,""):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
def _id_list(value):
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value,(list,tuple)):
|
||||
return [str(v) for v in value if v not in (None,"")]
|
||||
return [str(value)]
|
||||
|
||||
def serialize_candidate(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
@ -44,8 +32,6 @@ def serialize_candidate(row) -> dict:
|
|||
"summary_critique": row.summary_critique,
|
||||
"professional_summary": row.professional_summary or None,
|
||||
"linkedin_url": row.linkedin_url or None,
|
||||
"inbox_message_id": str(row.inbox_message_id) if getattr(row, "inbox_message_id", None) else None,
|
||||
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if getattr(row, "manual_upload_candidate_id", None) else None,
|
||||
"status": row.status,
|
||||
"error_code": row.error_code,
|
||||
"error_message": row.error_message,
|
||||
|
|
@ -86,13 +72,11 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
prefixed because the two sources have different key spaces and would
|
||||
otherwise collide in a merged list.
|
||||
|
||||
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
|
||||
by the CV Bank table. ai_score is filled after serialize by joining the
|
||||
latest candidates row for this email — this function leaves it None.
|
||||
Speculative uploads have no inbox suggestions; suggested_job_post_ids is [].
|
||||
rank_score is the deterministic tier-1 overlap against whichever job the
|
||||
recruiter is ranking by; it is None until they pick one, and it is NOT an
|
||||
ATS score — ai_score is.
|
||||
"""
|
||||
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
||||
assigned=_id_str(row.job_post_id)
|
||||
return {
|
||||
"id":f"bank:{row.id}",
|
||||
"record_id":str(row.id),
|
||||
|
|
@ -106,7 +90,6 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
"current_company":(row.current_company or "").strip() or None,
|
||||
"current_position":(row.current_position or "").strip() or None,
|
||||
"education":(row.education or "").strip() or None,
|
||||
"city":(getattr(row,"city",None) or "").strip() or None,
|
||||
"skills":list(row.skills or []),
|
||||
"years_experience":row.years_experience,
|
||||
"ai_score":None,
|
||||
|
|
@ -116,13 +99,7 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
"bank_reason":(row.bank_reason or "").strip() or None,
|
||||
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||
"user_id":str(row.user_id) if row.user_id else None,
|
||||
"message_id":None,
|
||||
"assigned_job_post_id":assigned,
|
||||
"assigned_job_title":None,
|
||||
"scored_job_post_id":None,
|
||||
"scored_job_title":None,
|
||||
"suggested_job_post_ids":[],
|
||||
"suggested_jobs":[],
|
||||
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
||||
"created_at":row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at":row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -142,9 +119,6 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
||||
expires=get("bank_expires_at")
|
||||
created=get("created_at")
|
||||
assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id"))
|
||||
suggested=_id_list(row.get("suggested_job_post_ids"))
|
||||
last_title=get("last_job_title") or None
|
||||
return {
|
||||
"id":f"app:{get('inbox_id')}",
|
||||
"record_id":str(get("inbox_id")),
|
||||
|
|
@ -158,7 +132,6 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
"current_company":get("current_company") or None,
|
||||
"current_position":get("current_title") or None,
|
||||
"education":get("education") or None,
|
||||
"city":get("city") or None,
|
||||
# Inbox applications never ran the skills extraction — their structured
|
||||
# signal is the ATS score, which is stronger than a keyword list.
|
||||
"skills":list(row.get("matched_keywords") or []),
|
||||
|
|
@ -166,17 +139,11 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
|||
"ai_score":get("ai_score"),
|
||||
"recommendation":get("recommendation"),
|
||||
"rank_score":rank_score,
|
||||
"last_job_title":last_title,
|
||||
"last_job_title":get("last_job_title") or None,
|
||||
"bank_reason":"silver_medalist",
|
||||
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
||||
"user_id":str(get("user_id")) if get("user_id") else None,
|
||||
"message_id":_id_str(get("message_id")),
|
||||
"assigned_job_post_id":assigned,
|
||||
"assigned_job_title":last_title,
|
||||
"scored_job_post_id":assigned,
|
||||
"scored_job_title":last_title,
|
||||
"suggested_job_post_ids":suggested,
|
||||
"suggested_jobs":[],
|
||||
"assigned_job_post_id":None,
|
||||
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
||||
"updated_at":None,
|
||||
}
|
||||
|
|
@ -305,7 +272,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
"message_id": None,
|
||||
"created_at": created,
|
||||
"application_status": row.status or None,
|
||||
"experience": (row.experience or "").strip() or (str(row.years_experience) if row.years_experience is not None else None),
|
||||
"experience": (row.experience or "").strip() or None,
|
||||
"current_employment": company,
|
||||
"current_title": position,
|
||||
"resume_text": row.full_text or None,
|
||||
|
|
@ -321,7 +288,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
"favorite": None,
|
||||
"rating": None,
|
||||
"phone": (row.candidate_phone or "").strip() or None,
|
||||
"education": (row.education or "").strip() or None,
|
||||
"education": None,
|
||||
"currentCompany": company,
|
||||
"stage": row.status or None,
|
||||
"source": (row.platform or "").strip() or None,
|
||||
|
|
@ -346,79 +313,6 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
|
||||
return {
|
||||
"inbox_id": None,
|
||||
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
|
||||
"user_id": profile.get("user_id"),
|
||||
"candidate_id": None,
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"is_active": profile.get("is_active"),
|
||||
"message_id": None,
|
||||
"created_at": profile.get("created_at"),
|
||||
"application_status": profile.get("application_status"),
|
||||
"experience": profile.get("experience"),
|
||||
"current_employment": profile.get("current_employment"),
|
||||
"current_title": profile.get("current_title"),
|
||||
"resume_text": None,
|
||||
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
|
||||
"assigned_job_post_id": profile.get("assigned_job_post_id"),
|
||||
"job_posts": profile.get("job_posts") or [],
|
||||
"assigned_job_post": profile.get("assigned_job_post"),
|
||||
"job_title": profile.get("job_title"),
|
||||
"recruiter": profile.get("recruiter"),
|
||||
"recruiter_id": profile.get("recruiter_id"),
|
||||
"source": profile.get("source"),
|
||||
"file_path": profile.get("file_path"),
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_form_candidate_list(row) -> Dict[str, Any]:
|
||||
"""GET /candidate/fetch list row for an unpromoted FormData application.
|
||||
|
||||
processing_state is an inbox tab, not Candidate_application_Status, so it
|
||||
is not copied onto application_status — CLOSED/rejected-tab values would
|
||||
paint every sheet row as Rejected on Candidates.
|
||||
"""
|
||||
assigned = row.assigned_job_post_id or row.job_post_id
|
||||
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
|
||||
created = row.created_at.isoformat() if row.created_at else None
|
||||
name = (row.name or "").strip() or None
|
||||
email = (row.candidate_email or "").strip() or None
|
||||
return {
|
||||
"inbox_id": None,
|
||||
"form_data_id": str(row.id),
|
||||
"manual_upload_candidate_id": None,
|
||||
"user_id": None,
|
||||
"candidate_id": None,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"is_active": None,
|
||||
"message_id": None,
|
||||
"created_at": created,
|
||||
"application_status": None,
|
||||
"experience": (row.experience or "").strip() or None,
|
||||
"current_employment": (row.current_company or "").strip() or None,
|
||||
"current_title": (row.position_applied_for or "").strip() or None,
|
||||
"resume_text": None,
|
||||
"suggested_job_post_ids": suggested,
|
||||
"assigned_job_post_id": str(assigned) if assigned else None,
|
||||
"job_posts": [],
|
||||
"assigned_job_post": None,
|
||||
"job_title": (row.position_applied_for or "").strip() or None,
|
||||
"recruiter": None,
|
||||
"recruiter_id": None,
|
||||
"source": "Form",
|
||||
"file_path": None,
|
||||
"ai_score": None,
|
||||
"recommendation": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_manager_candidate(row, *, source) -> dict:
|
||||
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||
inbox_id = row.get("inbox_id")
|
||||
|
|
@ -453,8 +347,9 @@ _WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
|
|||
def is_assigned_application(row) -> bool:
|
||||
"""True when the row is an application to a real job, not an unassigned email.
|
||||
|
||||
Sheet forms name a role in job_title even before a job post is linked.
|
||||
Unassigned inbox mail is still a kept attempt — see is_kept_application.
|
||||
Reapplied means they applied to a role before. Another inbox mail with no
|
||||
job post is still history; it is not a reapplication. Sheet forms name a
|
||||
role in job_title even before a job post is linked.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
|
|
@ -465,24 +360,6 @@ def is_assigned_application(row) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def is_kept_application(row) -> bool:
|
||||
"""True when the row is a real application, including unassigned inbox mail.
|
||||
|
||||
A CV attachment counts even when text extraction failed (`no_text`) — they
|
||||
still applied. Body-only mail and classifier drops stay in history but do
|
||||
not count as a reapplication. Two On-Hold emails from the same person do.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
if row.get("source") == "filtered":
|
||||
return False
|
||||
if row.get("source") == "inbox" and row.get("attachment") is False:
|
||||
return False
|
||||
if row.get("source") == "inbox" and row.get("attachment") is True:
|
||||
return True
|
||||
return rejection_reason(row) != "wrong_format"
|
||||
|
||||
|
||||
def rejection_reason(row) -> str | None:
|
||||
"""Why an unassigned attempt never reached a job — or None if it is still open.
|
||||
|
||||
|
|
@ -529,12 +406,7 @@ def serialize_application_history_item(row) -> dict:
|
|||
|
||||
|
||||
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
|
||||
items = [
|
||||
item for item in (
|
||||
serialize_application_history_item(row) for row in (applications or [])
|
||||
)
|
||||
if is_kept_application(item)
|
||||
]
|
||||
items = [serialize_application_history_item(row) for row in (applications or [])]
|
||||
found = bool(user or present_in or items)
|
||||
return {
|
||||
"email": email,
|
||||
|
|
@ -544,6 +416,6 @@ def serialize_application_history(email, *, user=None, present_in=None, applicat
|
|||
{"id": str(user.id), "name": user.name, "email": user.email}
|
||||
if user is not None else None
|
||||
),
|
||||
"is_reapplicant": len(items) > 1,
|
||||
"is_reapplicant": any(is_assigned_application(item) for item in items),
|
||||
"applications": items,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from fastapi import HTTPException
|
|||
from pypdf import PdfReader
|
||||
from app.core.errors import ATSError,ErrorCode
|
||||
from app.models.scoring import CompletedCandidate
|
||||
from app.services.pdf import ExtractedResume,extract_resume,sanitize_filename
|
||||
from app.services.pdf import extract_resume,sanitize_filename
|
||||
from app.services.scoring import score_batch
|
||||
from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,InboxRescanRun,AtsResults
|
||||
from job.candidate.models import Candidates
|
||||
|
|
@ -25,7 +25,7 @@ from job.candidate.plugins import (
|
|||
normalize_spaced_text,
|
||||
)
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.serializers import is_kept_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_form_candidate_list,serialize_manual_candidate_list,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||
|
|
@ -193,8 +193,8 @@ async def owned_job_ids_for_candidate_scope(session,current_user,created_by=Fals
|
|||
requisitions.configure (or hiring-manager portal) → jobs on their requisitions
|
||||
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide
|
||||
those candidates. candidates.manage or admin → None (all applications).
|
||||
Otherwise → current_recruiter_ids / current_recruiter_id when set, else created_by. Never role_id.
|
||||
created_by=True skips recruiter assignment and matches job_posts.created_by
|
||||
Otherwise → current_recruiter_id when set, else created_by. Never role_id.
|
||||
created_by=True skips current_recruiter_id and matches job_posts.created_by
|
||||
to the session user (ignored when the user is requisition-scoped).
|
||||
"""
|
||||
if scopes_to_own_requisitions(current_user):
|
||||
|
|
@ -204,36 +204,14 @@ async def owned_job_ids_for_candidate_scope(session,current_user,created_by=Fals
|
|||
return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
|
||||
|
||||
|
||||
def _requested_job_post_ids(assigned_job_post_id):
|
||||
"""Comma-separated or list of job post ids → UUID list. None = no filter."""
|
||||
if assigned_job_post_id is None:
|
||||
return None
|
||||
if isinstance(assigned_job_post_id,(list,tuple,set)):
|
||||
parts=list(assigned_job_post_id)
|
||||
else:
|
||||
text=str(assigned_job_post_id).strip()
|
||||
if not text:
|
||||
return None
|
||||
parts=[p.strip() for p in text.split(",") if p.strip()]
|
||||
ids=[]
|
||||
seen=set()
|
||||
for part in parts:
|
||||
uid=JobPosts._as_uuid(part)
|
||||
if uid is not None and uid not in seen:
|
||||
seen.add(uid)
|
||||
ids.append(uid)
|
||||
return ids
|
||||
|
||||
|
||||
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
|
||||
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
|
||||
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
|
||||
requested=_requested_job_post_ids(assigned_job_post_id)
|
||||
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
|
||||
if owned is None:
|
||||
return requested
|
||||
return [requested] if requested else None
|
||||
if requested is not None:
|
||||
owned_set=set(owned)
|
||||
return [jid for jid in requested if jid in owned_set]
|
||||
return [requested] if requested in set(owned) else []
|
||||
return list(owned)
|
||||
|
||||
|
||||
|
|
@ -296,14 +274,12 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
|
|||
"""
|
||||
blank={
|
||||
"linkedin_url":None,"current_company":"","current_position":"",
|
||||
"education":"","candidate_phone":"","city":None,"skills":[],
|
||||
"years_experience":None,"candidate_name":"",
|
||||
"education":"","candidate_phone":"","city":None,"skills":[],"years_experience":None,
|
||||
}
|
||||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
return blank
|
||||
try:
|
||||
from employment_agent.decorators import _clean_years
|
||||
from employment_agent.execute_agent import run_employment_agent
|
||||
from employment_agent.plugins import parse_linkedin
|
||||
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY
|
||||
|
|
@ -320,7 +296,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
|
|||
url=parse_linkedin({"linkedin_url":fields.get("linkedin_url") or ""},text).get("linkedin_url")
|
||||
except Exception:
|
||||
url=None
|
||||
years=_clean_years(fields.get("years_experience"),text)
|
||||
years=fields.get("years_experience")
|
||||
return {
|
||||
"linkedin_url":url,
|
||||
"current_company":unless_sentinel("current_employment",NO_COMPANY),
|
||||
|
|
@ -329,8 +305,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
|
|||
"candidate_phone":(fields.get("phone") or "").strip(),
|
||||
"city":(fields.get("city") or "").strip() or None,
|
||||
"skills":fields.get("skills") if isinstance(fields.get("skills"),list) else [],
|
||||
"years_experience":years,
|
||||
"candidate_name":(fields.get("candidate_name") or "").strip(),
|
||||
"years_experience":years if isinstance(years,int) else None,
|
||||
}
|
||||
|
||||
def _bank_row_matches(row,*,search=None,skills=None,min_years=None,band=None) -> bool:
|
||||
|
|
@ -705,7 +680,6 @@ class CandidateScoring:
|
|||
if not row.file_path:
|
||||
continue
|
||||
names=[n.strip() for n in (row.file_name or "").split(",") if n.strip()]
|
||||
stored=(row.resume_text or "").strip()
|
||||
for idx,path_str in enumerate(p.strip() for p in row.file_path.split(",") if p.strip()):
|
||||
name=names[idx] if idx<len(names) else Path(path_str.replace("\\","/")).name or "resume.pdf"
|
||||
source={
|
||||
|
|
@ -717,9 +691,6 @@ class CandidateScoring:
|
|||
"professional_summary":None,
|
||||
"precheck":None,
|
||||
}
|
||||
if stored:
|
||||
source["resume_text"]=stored
|
||||
source["stored_resume_text"]=stored
|
||||
source["professional_summary"]=await self._professional_summary_for_email(
|
||||
source["candidate_email"],row.professional_summary,
|
||||
)
|
||||
|
|
@ -727,9 +698,6 @@ class CandidateScoring:
|
|||
if source["precheck"] is not None:
|
||||
sources.append(source)
|
||||
continue
|
||||
if stored:
|
||||
sources.append(source)
|
||||
continue
|
||||
lower=name.lower()
|
||||
if lower.endswith(".doc") or lower.endswith(".docx"):
|
||||
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
|
||||
|
|
@ -792,10 +760,6 @@ class CandidateScoring:
|
|||
"professional_summary":None,
|
||||
"precheck":None,
|
||||
}
|
||||
stored=(row.full_text or "").strip()
|
||||
if stored:
|
||||
source["resume_text"]=stored
|
||||
source["stored_resume_text"]=stored
|
||||
source["professional_summary"]=await self._professional_summary_for_email(
|
||||
source["candidate_email"],row.professional_summary,
|
||||
)
|
||||
|
|
@ -803,9 +767,6 @@ class CandidateScoring:
|
|||
if source["precheck"] is not None:
|
||||
sources.append(source)
|
||||
continue
|
||||
if stored:
|
||||
sources.append(source)
|
||||
continue
|
||||
file_row=await CvBankFiles.get(self.session,row.id)
|
||||
data=file_row.data if file_row and file_row.data else None
|
||||
if data is None and source["file_path"]:
|
||||
|
|
@ -842,127 +803,7 @@ class CandidateScoring:
|
|||
data=serialize_candidate(row)
|
||||
return await CandidateView(session=self.session).attach_application_history(data)
|
||||
|
||||
async def rerun_ats(self,current_user,user_id=None,inbox_message_id=None,manual_upload_candidate_id=None):
|
||||
"""Re-score one candidate without re-reading the PDF.
|
||||
|
||||
Prefer candidates.professional_summary as the scoring text. Fall back to
|
||||
inbox_messages.resume_text / manual_upload_candidate.full_text. Only parse
|
||||
the file when nothing is stored, then write that extract onto the inbox
|
||||
or manual row and stamp the FK on candidates.
|
||||
"""
|
||||
from inbox.plugins import extract_resume_text
|
||||
|
||||
message=None
|
||||
manual=None
|
||||
email=None
|
||||
if inbox_message_id:
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,inbox_message_id)
|
||||
if message is None:
|
||||
raise HTTPException(status_code=404,detail="Inbox message not found")
|
||||
elif user_id:
|
||||
links=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
||||
records=links if isinstance(links,list) else ([links] if links else [])
|
||||
picked=None
|
||||
for link in records:
|
||||
msg=getattr(link,"messages",None)
|
||||
if msg is not None and msg.assigned_job_post_id:
|
||||
picked=link
|
||||
break
|
||||
if picked is None and records:
|
||||
picked=records[0]
|
||||
if picked is None:
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if manual is None:
|
||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||
else:
|
||||
message=getattr(picked,"messages",None)
|
||||
user=getattr(picked,"user",None)
|
||||
email=(getattr(user,"email",None) or "").strip().lower() or None
|
||||
elif manual_upload_candidate_id:
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_candidate_id)
|
||||
if manual is None:
|
||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||
else:
|
||||
raise HTTPException(status_code=400,detail="user_id, inbox_message_id or manual_upload_candidate_id is required")
|
||||
|
||||
if message is None and manual is None:
|
||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||
|
||||
job_id=None
|
||||
filename="resume.pdf"
|
||||
file_path=None
|
||||
stored_extract=None
|
||||
inbox_summary=None
|
||||
inbox_mid=None
|
||||
manual_id=None
|
||||
existing_sha=None
|
||||
|
||||
if message is not None:
|
||||
inbox_mid=message.id
|
||||
email=email or (message.message_from or "").strip().lower() or None
|
||||
suggested=list(message.suggested_job_post_ids or [])
|
||||
job_id=message.assigned_job_post_id or (suggested[0] if suggested else None)
|
||||
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
|
||||
filename=names[0] if names else "resume.pdf"
|
||||
paths=[p.strip() for p in (message.file_path or "").split(",") if p.strip()]
|
||||
file_path=paths[0] if paths else None
|
||||
stored_extract=(message.resume_text or "").strip() or None
|
||||
inbox_summary=(message.professional_summary or "").strip() or None
|
||||
else:
|
||||
manual_id=manual.id
|
||||
email=email or (manual.candidate_email or "").strip().lower() or None
|
||||
job_id=manual.job_post_id
|
||||
filename=(manual.file_name or "").strip() or "resume.pdf"
|
||||
file_path=(manual.file_path or "").strip() or None
|
||||
stored_extract=(manual.full_text or "").strip() or None
|
||||
inbox_summary=(manual.professional_summary or "").strip() or None
|
||||
|
||||
if job_id is None:
|
||||
raise HTTPException(status_code=422,detail="Assign a job before running ATS")
|
||||
|
||||
scored=await Candidates.get_completed_by_email_job(self.session,email,job_id) if email else None
|
||||
table_summary=(scored.professional_summary or "").strip() if scored else None
|
||||
if scored and scored.content_sha256:
|
||||
existing_sha=scored.content_sha256
|
||||
identity_summary=await self._professional_summary_for_email(email,inbox_summary)
|
||||
summary=(table_summary or identity_summary or "").strip() or None
|
||||
stored_extract=(stored_extract or "").strip() or None
|
||||
|
||||
score_text=summary or stored_extract
|
||||
if not score_text:
|
||||
paths=[p.strip() for p in (file_path or "").split(",") if p.strip()] if file_path else []
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400,detail="No professional summary or stored CV text to score")
|
||||
text,extract_err=await extract_resume_text(paths)
|
||||
score_text=(text or "").strip() or None
|
||||
if not score_text:
|
||||
raise HTTPException(status_code=400,detail=extract_err or "No professional summary or stored CV text to score")
|
||||
stored_extract=score_text
|
||||
|
||||
if inbox_mid is not None and stored_extract:
|
||||
await Inbox_Messages.set_resume_text(self.session,inbox_mid,stored_extract)
|
||||
if manual_id is not None and stored_extract:
|
||||
await Manual_UPLOAD_CANDIDATE.set_full_text(self.session,manual_id,stored_extract)
|
||||
|
||||
source={
|
||||
"filename":filename,
|
||||
"data":None,
|
||||
"file_path":file_path,
|
||||
"resume_text":score_text,
|
||||
"stored_resume_text":stored_extract,
|
||||
"sha256":existing_sha or (
|
||||
hashlib.sha256(stored_extract.encode("utf-8")).hexdigest() if stored_extract else None
|
||||
),
|
||||
"inbox_message_id":inbox_mid,
|
||||
"manual_upload_candidate_id":manual_id,
|
||||
"candidate_email":email,
|
||||
"professional_summary":summary,
|
||||
"precheck":None,
|
||||
}
|
||||
kind="inbox" if inbox_mid is not None else "upload"
|
||||
return await self._score_and_persist(job_id, [source], kind, current_user, skip_gate=True)
|
||||
|
||||
async def _score_and_persist(self,job_id,sources,source_kind,current_user,rescan_run_id=None,skip_gate=False):
|
||||
async def _score_and_persist(self,job_id,sources,source_kind,current_user,rescan_run_id=None):
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||
if job is None or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
|
|
@ -976,23 +817,21 @@ class CandidateScoring:
|
|||
source["professional_summary"]=await self._professional_summary_for_email(
|
||||
source.get("candidate_email"),
|
||||
)
|
||||
if not skip_gate:
|
||||
await self._gate_source(source,jd)
|
||||
await self._gate_source(source,jd)
|
||||
fields_by_slot=await self._score_sources(sources,jd,settings)
|
||||
# Prefer the Manual S3 URL for this email+job when scoring from a raw upload
|
||||
# (Add Candidate scores right after create — same link as manual_upload_candidate).
|
||||
for slot,source in enumerate(sources):
|
||||
fields=fields_by_slot.get(slot) or {}
|
||||
if (fields.get("file_path") or source.get("file_path") or "").strip():
|
||||
continue
|
||||
email=(fields.get("candidate_email") or source.get("candidate_email") or "").strip().lower()
|
||||
if email and not source.get("manual_upload_candidate_id") and not fields.get("manual_upload_candidate_id"):
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
|
||||
if manual:
|
||||
source["manual_upload_candidate_id"]=manual.id
|
||||
fields["manual_upload_candidate_id"]=manual.id
|
||||
if not (fields.get("file_path") or source.get("file_path") or "").strip() and (manual.file_path or "").strip():
|
||||
fields["file_path"]=manual.file_path.strip()
|
||||
source["file_path"]=manual.file_path.strip()
|
||||
await self._persist_extracts(sources)
|
||||
if not email:
|
||||
continue
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job(self.session,email,job.id)
|
||||
if manual and (manual.file_path or "").strip():
|
||||
fields["file_path"]=manual.file_path.strip()
|
||||
source["file_path"]=manual.file_path.strip()
|
||||
common={
|
||||
"job_id":job.id,
|
||||
"source":source_kind,
|
||||
|
|
@ -1024,36 +863,17 @@ class CandidateScoring:
|
|||
|
||||
async def _score_sources(self,sources,jd,settings):
|
||||
# Slot-indexed: results merge back by position, never by filename —
|
||||
# inbox attachments can share a basename. Stored resume_text skips PDF parse.
|
||||
# inbox attachments can share a basename.
|
||||
fields_by_slot={}
|
||||
extracted=[]
|
||||
for slot,source in enumerate(sources):
|
||||
source["safe_name"]=sanitize_filename(source.get("filename") or "resume.pdf")
|
||||
if source.get("precheck") is not None:
|
||||
source["safe_name"]=sanitize_filename(source["filename"])
|
||||
data=source["data"]
|
||||
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
||||
if source["precheck"] is not None:
|
||||
code,message=source["precheck"]
|
||||
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
|
||||
continue
|
||||
stored=(source.get("resume_text") or "").strip()
|
||||
if stored:
|
||||
text=normalize_spaced_text(stored)
|
||||
if len(text)>settings.max_resume_chars:
|
||||
text=text[:settings.max_resume_chars]
|
||||
if not source.get("sha256"):
|
||||
source["sha256"]=hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
if not source.get("candidate_email"):
|
||||
detected,_=extract_candidate_email(text)
|
||||
source["candidate_email"]=detected
|
||||
resume=ExtractedResume(
|
||||
filename=source["safe_name"],
|
||||
candidate_id=str(source.get("inbox_message_id") or source.get("manual_upload_candidate_id") or uuid.uuid4()),
|
||||
text=text,
|
||||
page_count=1,
|
||||
truncated=False,
|
||||
)
|
||||
extracted.append((slot,resume))
|
||||
continue
|
||||
data=source.get("data")
|
||||
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
|
||||
try:
|
||||
resume=await asyncio.to_thread(
|
||||
extract_resume,data,source["safe_name"],settings.max_resume_chars
|
||||
|
|
@ -1062,8 +882,6 @@ class CandidateScoring:
|
|||
if not source.get("candidate_email"):
|
||||
detected,_=extract_candidate_email(resume.text)
|
||||
source["candidate_email"]=detected
|
||||
if not source.get("stored_resume_text"):
|
||||
source["stored_resume_text"]=resume.text
|
||||
except ATSError as exc:
|
||||
fields_by_slot[slot]=candidate_failed_fields(source,exc.error_code,exc.public_message)
|
||||
continue
|
||||
|
|
@ -1082,19 +900,6 @@ class CandidateScoring:
|
|||
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
|
||||
return fields_by_slot
|
||||
|
||||
async def _persist_extracts(self,sources):
|
||||
"""Write PDF extract onto inbox_messages / manual_upload_candidate, never onto candidates."""
|
||||
for source in sources:
|
||||
extract=(source.get("stored_resume_text") or "").strip()
|
||||
if not extract:
|
||||
continue
|
||||
mid=source.get("inbox_message_id")
|
||||
if mid is not None:
|
||||
await Inbox_Messages.set_resume_text(self.session,mid,extract)
|
||||
uid=source.get("manual_upload_candidate_id")
|
||||
if uid is not None:
|
||||
await Manual_UPLOAD_CANDIDATE.set_full_text(self.session,uid,extract)
|
||||
|
||||
async def _sync_ats_results(self,source_kind,job,rows,sources,current_user=None,rescan_run_id=None):
|
||||
run_id=self._as_rescan_run_id(rescan_run_id)
|
||||
if source_kind=="inbox":
|
||||
|
|
@ -1399,7 +1204,7 @@ class CandidateView:
|
|||
page=merged[start:start+cap]
|
||||
return await self.attach_application_history(page),total
|
||||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False,assignment=None):
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||
try:
|
||||
if not user_id and is_hiring_manager(current_user):
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
|
|
@ -1407,161 +1212,136 @@ class CandidateView:
|
|||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=user_id,created_by=created_by,
|
||||
)
|
||||
return await self._get_candidate_detail(
|
||||
user_id,limit=limit,offset=offset,search=search,
|
||||
current_user=current_user,created_by=created_by,
|
||||
detail=bool(user_id)
|
||||
list_job_ids=None
|
||||
if not detail:
|
||||
list_job_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
)
|
||||
list_job_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
job_post_ids=list_job_ids,
|
||||
)
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
return await self._list_candidates(
|
||||
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,assignment=assignment,
|
||||
if detail:
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
# row — resolve the profile from that table instead of returning [].
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
from inbox.plugins import get_ats_score_for_manual_user
|
||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
# List mode: inbox applications + manual/form applications (dedupe by user).
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
|
||||
)
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
manual_payloads=[]
|
||||
for manual in manual_rows:
|
||||
uid=str(manual.user_id) if manual.user_id else None
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
# List shape matches attach_job_posts: keep job_posts, drop heavy detail.
|
||||
manual_payloads.append({
|
||||
"inbox_id":None,
|
||||
"manual_upload_candidate_id":payload["manual_upload_candidate_id"],
|
||||
"user_id":payload["user_id"],
|
||||
"candidate_id":None,
|
||||
"name":payload["name"],
|
||||
"email":payload["email"],
|
||||
"is_active":payload.get("is_active"),
|
||||
"message_id":None,
|
||||
"created_at":payload.get("created_at"),
|
||||
"application_status":payload.get("application_status"),
|
||||
"experience":payload.get("experience"),
|
||||
"current_employment":payload.get("current_employment"),
|
||||
"current_title":payload.get("current_title"),
|
||||
"resume_text":None,
|
||||
"suggested_job_post_ids":[],
|
||||
"assigned_job_post_id":payload.get("assigned_job_post_id"),
|
||||
"job_posts":payload.get("job_posts") or [],
|
||||
"assigned_job_post":payload.get("assigned_job_post"),
|
||||
"job_title":payload.get("job_title"),
|
||||
"recruiter":payload.get("recruiter"),
|
||||
"recruiter_id":payload.get("recruiter_id"),
|
||||
"source":payload.get("source"),
|
||||
"file_path":payload.get("file_path"),
|
||||
"ai_score":None,
|
||||
"recommendation":None,
|
||||
})
|
||||
if uid:
|
||||
seen.add(uid)
|
||||
from inbox.plugins import get_ats_scores_for_users
|
||||
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in manual_payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=row["overall_score"]
|
||||
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||
data=inbox_payloads+manual_payloads
|
||||
if assigned_job_post_id:
|
||||
job_id=str(assigned_job_post_id)
|
||||
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
|
||||
return await self.attach_application_history(data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def _get_candidate_detail(self,user_id,limit=10,offset=0,search=None,current_user=None,created_by=False):
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
)
|
||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
return await self._get_manual_detail(user_id,owned,current_user)
|
||||
|
||||
async def _get_manual_detail(self,user_id,owned,current_user):
|
||||
from inbox.plugins import get_ats_score_for_manual_user
|
||||
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if manual.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score and score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
|
||||
async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None,assignment=None):
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,limit=limit,offset=offset,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||
)
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
|
||||
manual_payloads=await self._list_manual_payloads(
|
||||
limit=limit,search=search,job_post_ids=job_post_ids,seen=seen,seen_emails=seen_emails,assignment=assignment,
|
||||
)
|
||||
form_payloads=await self._list_form_payloads(
|
||||
limit=limit,search=search,job_post_ids=job_post_ids,seen_emails=seen_emails,assignment=assignment,
|
||||
)
|
||||
return await self.attach_application_history(inbox_payloads+manual_payloads+form_payloads)
|
||||
|
||||
async def _list_manual_payloads(self,limit,search,job_post_ids,seen,seen_emails,assignment=None):
|
||||
if assignment == "unassigned":
|
||||
return []
|
||||
rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
payloads=[]
|
||||
for row in rows:
|
||||
uid=str(row.user_id) if row.user_id else None
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,row.user_id) if row.user_id else None
|
||||
job_post=None
|
||||
if row.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||
payload=serialize_manual_candidate_list(serialize_manual_candidate_profile(row,user,job_post))
|
||||
payloads.append(payload)
|
||||
if uid:
|
||||
seen.add(uid)
|
||||
email=(payload.get("email") or "").strip().lower()
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
await self._attach_user_ats(payloads)
|
||||
return payloads
|
||||
|
||||
async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails,assignment=None):
|
||||
rows=await FormData.list_for_talent_pool(
|
||||
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||
)
|
||||
payloads=[]
|
||||
for rec in rows:
|
||||
payload=serialize_form_candidate_list(rec)
|
||||
email=(payload.get("email") or "").strip().lower()
|
||||
if email and email in seen_emails:
|
||||
continue
|
||||
payloads.append(payload)
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
payloads=await self._hydrate_list_jobs(payloads)
|
||||
await self._attach_form_ats(payloads)
|
||||
return payloads
|
||||
|
||||
async def _attach_user_ats(self,payloads):
|
||||
from inbox.plugins import get_ats_scores_for_users
|
||||
owners=[p.get("user_id") for p in payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=row["overall_score"]
|
||||
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||
|
||||
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False,assignment=None):
|
||||
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||
try:
|
||||
job_post_ids=None
|
||||
if not user_id:
|
||||
job_post_ids=await job_post_ids_for_candidate_list(
|
||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||
)
|
||||
if job_post_ids is not None and not job_post_ids:
|
||||
return 0
|
||||
inbox_n=await Inbox.count_candidate_profiles(
|
||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||
return await Inbox.count_candidate_profiles(
|
||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
if user_id:
|
||||
return inbox_n
|
||||
manual_n=0 if assignment == "unassigned" else await Manual_UPLOAD_CANDIDATE.count_for_talent_pool(
|
||||
self.session,search=search,job_post_ids=job_post_ids,
|
||||
)
|
||||
form_n=await FormData.count_for_talent_pool(
|
||||
self.session,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||
)
|
||||
return inbox_n+manual_n+form_n
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -1630,47 +1410,6 @@ class CandidateView:
|
|||
data["job_title"]=payload.get("title")
|
||||
return payload
|
||||
|
||||
async def _hydrate_list_jobs(self,payloads):
|
||||
"""Attach assigned + suggested job posts onto already-serialized list rows."""
|
||||
wanted=[]
|
||||
for payload in payloads:
|
||||
if payload.get("assigned_job_post_id"):
|
||||
wanted.append(payload["assigned_job_post_id"])
|
||||
wanted.extend(payload.get("suggested_job_post_ids") or [])
|
||||
posts=await self._job_posts_by_id(wanted)
|
||||
for payload in payloads:
|
||||
assigned_id=payload.get("assigned_job_post_id")
|
||||
if assigned_id:
|
||||
post=posts.get(str(assigned_id))
|
||||
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
|
||||
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||
if assigned_id and str(job_id)==str(assigned_id):
|
||||
continue
|
||||
post=posts.get(str(job_id))
|
||||
self._attach_job_post(payload,dict(post) if post else None)
|
||||
return payloads
|
||||
|
||||
async def _attach_form_ats(self,payloads):
|
||||
grouped=await AtsResults.get_current_for_forms(
|
||||
self.session,[p.get("form_data_id") for p in payloads],
|
||||
)
|
||||
for payload in payloads:
|
||||
fid=payload.get("form_data_id")
|
||||
try:
|
||||
key=uuid.UUID(str(fid)) if fid else None
|
||||
except (TypeError,ValueError):
|
||||
key=None
|
||||
rows=grouped.get(key) or [] if key is not None else []
|
||||
assigned=payload.get("assigned_job_post_id")
|
||||
chosen=None
|
||||
if assigned:
|
||||
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
|
||||
if chosen is None and rows:
|
||||
chosen=rows[0]
|
||||
if chosen is not None and chosen.overall_score is not None:
|
||||
payload["ai_score"]=chosen.overall_score
|
||||
payload["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
|
||||
|
||||
async def _job_posts_by_id(self,ids):
|
||||
"""Serialized job posts keyed by id — one query for a whole page of rows.
|
||||
|
||||
|
|
@ -1912,8 +1651,9 @@ class CandidateView:
|
|||
band=None,job_post_id=None,limit=50,offset=0):
|
||||
"""The unified CV Bank: speculative uploads plus scored rejections.
|
||||
|
||||
job_post_id does not filter — it is only used by /cv-bank/suggestions
|
||||
to attach rank_score. The CV Bank screen itself no longer ranks.
|
||||
job_post_id does not filter — it attaches the tier-1 rank_score for
|
||||
that job and sorts by it, which is how a recruiter "pulls from" the
|
||||
bank when an opening appears.
|
||||
"""
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist
|
||||
|
|
@ -1930,8 +1670,6 @@ class CandidateView:
|
|||
)
|
||||
rows.extend(serialize_bank_silver_medalist(r) for r in medalists)
|
||||
|
||||
rows=await self._attach_bank_ats(rows)
|
||||
rows=await self._hydrate_bank_job_titles(rows)
|
||||
if job_post_id:
|
||||
rows=await self._attach_rank_scores(rows,job_post_id)
|
||||
|
||||
|
|
@ -1947,68 +1685,6 @@ class CandidateView:
|
|||
|
||||
return int(os.getenv("CV_BANK_SILVER_FLOOR","60"))
|
||||
|
||||
async def _attach_bank_ats(self,rows):
|
||||
"""Join the latest completed ATS score onto speculative bank rows.
|
||||
|
||||
serialize_bank_candidate leaves ai_score None; scores live on
|
||||
candidates (and ats_results) keyed by email, written by score_bank.
|
||||
Silver medalists already carry the inbox denorm score.
|
||||
"""
|
||||
emails=[]
|
||||
for row in rows:
|
||||
if row.get("bank_source")!="speculative":
|
||||
continue
|
||||
email=(row.get("email") or "").strip().lower()
|
||||
if email:
|
||||
emails.append(email)
|
||||
if not emails:
|
||||
return rows
|
||||
latest=await Candidates.latest_completed_by_emails(self.session,emails)
|
||||
for row in rows:
|
||||
if row.get("bank_source")!="speculative":
|
||||
continue
|
||||
hit=latest.get((row.get("email") or "").strip().lower())
|
||||
if not hit:
|
||||
continue
|
||||
row["ai_score"]=hit.get("match_score")
|
||||
row["recommendation"]=self._recommendation(hit.get("match_score"))
|
||||
row["scored_job_post_id"]=hit.get("job_post_id")
|
||||
row["scored_job_title"]=hit.get("job_title")
|
||||
return rows
|
||||
|
||||
async def _hydrate_bank_job_titles(self,rows):
|
||||
"""Resolve assigned / scored / suggested job ids to titles in one fetch."""
|
||||
ids=[]
|
||||
seen=set()
|
||||
def add(jid):
|
||||
key=str(jid) if jid not in (None,"") else ""
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
ids.append(key)
|
||||
for row in rows:
|
||||
add(row.get("assigned_job_post_id"))
|
||||
add(row.get("scored_job_post_id"))
|
||||
for jid in row.get("suggested_job_post_ids") or []:
|
||||
add(jid)
|
||||
titles={}
|
||||
if ids:
|
||||
posts=await JobPosts.titles_by_ids(self.session,ids,active_only=False)
|
||||
titles={str(p.id):p.title for p in posts}
|
||||
for row in rows:
|
||||
assigned=str(row.get("assigned_job_post_id") or "")
|
||||
scored=str(row.get("scored_job_post_id") or "")
|
||||
if not row.get("assigned_job_title"):
|
||||
row["assigned_job_title"]=titles.get(assigned)
|
||||
if not row.get("scored_job_title"):
|
||||
row["scored_job_title"]=titles.get(scored)
|
||||
suggested=[]
|
||||
for jid in row.get("suggested_job_post_ids") or []:
|
||||
title=titles.get(str(jid))
|
||||
if title:
|
||||
suggested.append({"id":str(jid),"title":title})
|
||||
row["suggested_jobs"]=suggested
|
||||
return rows
|
||||
|
||||
async def _attach_rank_scores(self,rows,job_post_id):
|
||||
"""Fill rank_score from the stored tier-1 ranking for one job.
|
||||
|
||||
|
|
@ -2178,8 +1854,7 @@ class CandidateView:
|
|||
"""Stamp is_reapplicant + previous_applications onto list/detail dicts.
|
||||
|
||||
``previous_applications`` is every application for that email, including
|
||||
the open row. ``is_reapplicant`` means a *different* kept attempt —
|
||||
another email, form, or upload, even when neither has a job assigned.
|
||||
the open row. ``is_reapplicant`` still means a *different* assigned job.
|
||||
"""
|
||||
single=not isinstance(payloads,list)
|
||||
records=[payloads] if single else list(payloads or [])
|
||||
|
|
@ -2195,7 +1870,7 @@ class CandidateView:
|
|||
for row in pack.get("applications") or []:
|
||||
item=serialize_application_history_item(row)
|
||||
items.append(item)
|
||||
if is_kept_application(item) and not _is_current_application(row,payload):
|
||||
if is_assigned_application(item) and not _is_current_application(row,payload):
|
||||
reapplied=True
|
||||
items.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
payload["present_in"]=list(pack.get("present_in") or [])
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class HiringCosts(SQLModel, table=True):
|
|||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, false, func, or_, union_all
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased, load_only
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
|
@ -55,13 +54,8 @@ class JobPosts(SQLModel, table=True):
|
|||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
# Who is working the req now (swappable). History lives in job_assignments
|
||||
# with assignment_role=primary_recruiter; this column is the first / primary
|
||||
# pointer so existing joins keep working. current_recruiter_ids is the full
|
||||
# list (UUID strings) so more than one recruiter can sit on the same job.
|
||||
# with assignment_role=primary_recruiter; this column is the current pointer.
|
||||
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
current_recruiter_ids: list[str] = Field(
|
||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||
)
|
||||
# Who owns the requisition (stable). Optional. History lives in
|
||||
# job_assignments with assignment_role=hiring_manager.
|
||||
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
|
|
@ -83,58 +77,9 @@ class JobPosts(SQLModel, table=True):
|
|||
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except (TypeError, ValueError):
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def recruiter_ids_of(row) -> list[str]:
|
||||
"""UUID strings currently assigned as recruiters on a job row or mapping.
|
||||
|
||||
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
|
||||
row that has not been backfilled still maps to one person.
|
||||
"""
|
||||
if isinstance(row, dict):
|
||||
raw = row.get("current_recruiter_ids")
|
||||
fallback = row.get("current_recruiter_id")
|
||||
else:
|
||||
raw = getattr(row, "current_recruiter_ids", None)
|
||||
fallback = getattr(row, "current_recruiter_id", None)
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw or []:
|
||||
uid = JobPosts._as_uuid(item)
|
||||
if uid is None:
|
||||
continue
|
||||
key = str(uid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
if not out:
|
||||
uid = JobPosts._as_uuid(fallback)
|
||||
if uid is not None:
|
||||
out.append(str(uid))
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def has_recruiter(cls, recruiter_id):
|
||||
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
|
||||
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
|
||||
if uid is None:
|
||||
return false()
|
||||
return or_(
|
||||
cls.current_recruiter_id == uid,
|
||||
cls.current_recruiter_ids.contains([str(uid)]),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def no_recruiters(cls):
|
||||
"""SQL: neither the pointer nor the JSON list names anyone."""
|
||||
return and_(
|
||||
cls.current_recruiter_id.is_(None),
|
||||
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||
uid = cls._as_uuid(record_id)
|
||||
|
|
@ -512,7 +457,6 @@ class JobPosts(SQLModel, table=True):
|
|||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
cls.current_recruiter_ids,
|
||||
cls.created_at,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||
|
|
@ -609,7 +553,7 @@ class JobPosts(SQLModel, table=True):
|
|||
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
|
||||
"""Jobs this recruiter should see on Candidates (when they lack
|
||||
candidates.manage). created_by=True → created_by = session user only.
|
||||
Otherwise: current_recruiter_ids / current_recruiter_id when set, else created_by."""
|
||||
Otherwise: current_recruiter_id when set, else created_by."""
|
||||
uid = cls._as_uuid(user_id)
|
||||
if uid is None:
|
||||
return []
|
||||
|
|
@ -624,8 +568,8 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(
|
||||
select(cls.id).where(
|
||||
or_(
|
||||
cls.has_recruiter(uid),
|
||||
and_(cls.no_recruiters(), cls.created_by == uid),
|
||||
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
|
||||
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
|
||||
),
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
|
@ -655,12 +599,12 @@ class JobPosts(SQLModel, table=True):
|
|||
cls, session: AsyncSession, recruiter_id, *, status, department=None,
|
||||
from_date=None, to_date=None,
|
||||
):
|
||||
"""Requisitions owned by this recruiter (pointer or JSON list) in one status."""
|
||||
"""Requisitions owned by current_recruiter_id in one requisition_status."""
|
||||
uid = cls._as_uuid(recruiter_id)
|
||||
if uid is None:
|
||||
return 0
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.has_recruiter(uid),
|
||||
cls.current_recruiter_id == uid,
|
||||
cls.requisition_status == status,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
|
@ -679,7 +623,7 @@ class JobPosts(SQLModel, table=True):
|
|||
statement = statement.where(cls.department == department)
|
||||
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
|
||||
if uid is not None:
|
||||
statement = statement.where(cls.has_recruiter(uid))
|
||||
statement = statement.where(cls.current_recruiter_id == uid)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
def _status_label(value):
|
||||
|
|
@ -20,22 +19,7 @@ def serialize_job_post_title(row) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _recruiter_payload(row, names=None):
|
||||
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
|
||||
names = names or {}
|
||||
ids = JobPosts.recruiter_ids_of(row)
|
||||
mapped = [names.get(i) for i in ids]
|
||||
first = ids[0] if ids else None
|
||||
return {
|
||||
"current_recruiter_id": first,
|
||||
"current_recruiter_ids": ids,
|
||||
"recruiter_name": next((n for n in mapped if n), None),
|
||||
"recruiter_names": [n for n in mapped if n],
|
||||
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
|
||||
}
|
||||
|
||||
|
||||
def serialize_job_post(row, *, names=None) -> dict:
|
||||
def serialize_job_post(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
|
|
@ -64,11 +48,10 @@ def serialize_job_post(row, *, names=None) -> dict:
|
|||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
**_recruiter_payload(row, names),
|
||||
}
|
||||
|
||||
|
||||
def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
||||
def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
||||
"""Requisition view of a job post, for the Jobs screen.
|
||||
|
||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||
|
|
@ -76,9 +59,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
|||
talent-pool filters key off it on attached job_posts.
|
||||
"""
|
||||
req = getattr(row, "requisition", None)
|
||||
payload = _recruiter_payload(row, names)
|
||||
if recruiter_name and not payload["recruiter_name"]:
|
||||
payload["recruiter_name"] = recruiter_name
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
|
|
@ -99,7 +79,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
|||
"description": row.description,
|
||||
"is_active": row.is_active,
|
||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||
**payload,
|
||||
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
|
||||
"recruiter_name": recruiter_name,
|
||||
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
||||
"hiring_manager_name": hiring_manager_name,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
|
|
@ -113,19 +94,18 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
|||
}
|
||||
|
||||
|
||||
def serialize_job_stats(row, *, names=None) -> dict:
|
||||
def serialize_job_stats(row) -> dict:
|
||||
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
||||
recruiter_id=row.get("current_recruiter_id")
|
||||
created_at = row.get("created_at")
|
||||
payload = _recruiter_payload(row, names)
|
||||
if not payload["recruiter_name"] and row.get("recruiter_name"):
|
||||
payload["recruiter_name"] = row.get("recruiter_name")
|
||||
return {
|
||||
"job_post_id": str(row["job_post_id"]),
|
||||
"title": row["title"],
|
||||
"department": row["department"] or None,
|
||||
"location": row["location"],
|
||||
"requisition_status": row["requisition_status"],
|
||||
**payload,
|
||||
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
||||
"recruiter_name": row.get("recruiter_name") or None,
|
||||
# Frontend computes days-open vs client clock; no server days_open field.
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"total_applicants": int(row["total_applicants"] or 0),
|
||||
|
|
|
|||
|
|
@ -40,32 +40,6 @@ IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","web
|
|||
MAX_JOB_IMAGE_BYTES=5*1024*1024
|
||||
|
||||
|
||||
def _payload_recruiter_ids(payload):
|
||||
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
|
||||
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
|
||||
has_one="current_recruiter_id" in payload
|
||||
if has_list:
|
||||
raw=payload.get("current_recruiter_ids") or []
|
||||
if not isinstance(raw,(list,tuple)):
|
||||
raw=[raw]
|
||||
ids=list(raw)
|
||||
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
|
||||
ids=[payload.get("current_recruiter_id")]
|
||||
return ids
|
||||
if has_one:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
return [] if raw in (None,"") else [raw]
|
||||
return None
|
||||
|
||||
|
||||
def _recruiter_fields(users):
|
||||
ids=[str(u.id) for u in users]
|
||||
return {
|
||||
"current_recruiter_ids": ids,
|
||||
"current_recruiter_id": users[0].id if users else None,
|
||||
}
|
||||
|
||||
|
||||
def _job_image_key(job_post_id) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(job_post_id))
|
||||
|
|
@ -93,7 +67,6 @@ class JobPostCreate(BaseModel):
|
|||
due_at: str | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -111,31 +84,6 @@ class JobPost:
|
|||
self.buffer_api=os.getenv("BUFFER_API")
|
||||
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
|
||||
|
||||
async def _resolve_recruiters(self,assignment,raw_ids):
|
||||
"""Validate each id is an active recruiter. Dedup, preserve order."""
|
||||
users=[]
|
||||
seen=set()
|
||||
for raw in raw_ids or []:
|
||||
if raw in (None,""):
|
||||
continue
|
||||
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_ids")
|
||||
key=str(rec.id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
users.append(rec)
|
||||
return users
|
||||
|
||||
async def _names_for(self,row):
|
||||
ids=JobPosts.recruiter_ids_of(row)
|
||||
extra=[]
|
||||
if getattr(row,"hiring_manager_id",None):
|
||||
extra.append(row.hiring_manager_id)
|
||||
return await Users.names_by_ids(self.session,ids+extra)
|
||||
|
||||
async def _serialize_post(self,row):
|
||||
return serialize_job_post(row,names=await self._names_for(row))
|
||||
|
||||
async def _resolve_target(self,payload,aliases=None):
|
||||
"""Pick the Buffer channel to post to, and the service it belongs to.
|
||||
|
||||
|
|
@ -206,11 +154,12 @@ class JobPost:
|
|||
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
|
||||
)
|
||||
fields["hiring_manager_id"]=hm.id
|
||||
rec_users=[]
|
||||
raw_ids=_payload_recruiter_ids(payload)
|
||||
if raw_ids:
|
||||
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||
fields.update(_recruiter_fields(rec_users))
|
||||
rec=None
|
||||
if payload.get("current_recruiter_id"):
|
||||
rec=await assignment.require_role(
|
||||
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
|
||||
)
|
||||
fields["current_recruiter_id"]=rec.id
|
||||
|
||||
if payload.get("requisition_id"):
|
||||
from candidate_forms.models import Requisition
|
||||
|
|
@ -239,8 +188,8 @@ class JobPost:
|
|||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
if hm:
|
||||
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
|
||||
if rec_users:
|
||||
await assignment.record_job_recruiters(row.id,[u.id for u in rec_users],assigned_by)
|
||||
if rec:
|
||||
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
||||
|
||||
try:
|
||||
from notifications.views import notify_job_created
|
||||
|
|
@ -255,7 +204,7 @@ class JobPost:
|
|||
await self._rank_cv_bank(row.id)
|
||||
|
||||
if not publish:
|
||||
return await self._serialize_post(row)
|
||||
return serialize_job_post(row)
|
||||
|
||||
try:
|
||||
post=await create_buffer_post(
|
||||
|
|
@ -278,7 +227,7 @@ class JobPost:
|
|||
sent_at=parse_buffer_datetime(post.get("sentAt")),
|
||||
platform=post.get("channelService"),
|
||||
)
|
||||
return serialize_job_post(saved,names=await self._names_for(saved))
|
||||
return serialize_job_post(saved)
|
||||
|
||||
async def _rank_cv_bank(self,job_post_id):
|
||||
"""Queue the tier-1 rank of every banked CV against a brand-new job.
|
||||
|
|
@ -332,11 +281,7 @@ class JobPost:
|
|||
active_only=active_only,
|
||||
restrict_ids=restrict,
|
||||
)
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||
)
|
||||
return [serialize_job_post(r,names=names) for r in rows],total
|
||||
return [serialize_job_post(r) for r in rows],total
|
||||
|
||||
async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False):
|
||||
uid=None
|
||||
|
|
@ -353,11 +298,7 @@ class JobPost:
|
|||
skip=skip,
|
||||
active_only=active_only,
|
||||
)
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||
)
|
||||
data=[serialize_job_stats(r,names=names) for r in rows]
|
||||
data=[serialize_job_stats(r) for r in rows]
|
||||
if uid is not None:
|
||||
if not data:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
|
|
@ -400,13 +341,13 @@ class JobPost:
|
|||
)
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
|
||||
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
|
||||
)
|
||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
||||
return [
|
||||
serialize_job_row(
|
||||
r,
|
||||
names=names,
|
||||
recruiter_name=names.get(str(r.current_recruiter_id)),
|
||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||
applicant_count=counts.get(str(r.id),0),
|
||||
)
|
||||
|
|
@ -416,11 +357,11 @@ class JobPost:
|
|||
async def _job_row(self,row):
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
|
||||
[row.current_recruiter_id,row.hiring_manager_id],
|
||||
)
|
||||
return serialize_job_row(
|
||||
row,
|
||||
names=names,
|
||||
recruiter_name=names.get(str(row.current_recruiter_id)),
|
||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||
)
|
||||
|
||||
|
|
@ -474,12 +415,15 @@ class JobPost:
|
|||
detail="This requisition is already linked to a job post",
|
||||
)
|
||||
fields["requisition_id"]=req.id
|
||||
rec_users=None
|
||||
raw_ids=_payload_recruiter_ids(payload)
|
||||
if raw_ids is not None:
|
||||
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||
fields.update(_recruiter_fields(rec_users))
|
||||
rec_changed=JobPosts.recruiter_ids_of(existing)!=[str(u.id) for u in rec_users]
|
||||
if "current_recruiter_id" in payload:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
if raw is None or raw=="":
|
||||
fields["current_recruiter_id"]=None
|
||||
rec_changed=existing.current_recruiter_id is not None
|
||||
else:
|
||||
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
|
||||
fields["current_recruiter_id"]=rec.id
|
||||
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
|
|
@ -499,8 +443,8 @@ class JobPost:
|
|||
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
|
||||
)
|
||||
if rec_changed:
|
||||
await assignment.record_job_recruiters(
|
||||
job_post_id,fields.get("current_recruiter_ids") or [],assigned_by,
|
||||
await assignment.record_job_owner(
|
||||
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
|
||||
)
|
||||
if hm_changed or rec_changed:
|
||||
try:
|
||||
|
|
@ -514,7 +458,7 @@ class JobPost:
|
|||
self.session,row,
|
||||
role_label=" and ".join(labels),
|
||||
actor_id=assigned_by,
|
||||
previous_ids=[existing.hiring_manager_id,*JobPosts.recruiter_ids_of(existing)],
|
||||
previous_ids=[existing.hiring_manager_id,existing.current_recruiter_id],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
|
|
|
|||
|
|
@ -13,21 +13,21 @@ class Pipeline:
|
|||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def get_all(self,job_post_id=None,limit=10,offset=0,search=None):
|
||||
async def get_all(self,job_post_id=None,limit=10,offset=0):
|
||||
# limit/offset are per-source, not a merged page: two tables that cannot be
|
||||
# paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows,
|
||||
# each newest-first by created_at. `counts`/`total` stay full-set sizes so
|
||||
# the caller can drive paging off them.
|
||||
try:
|
||||
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
|
||||
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||
from job.candidate.views import CandidateView
|
||||
history=CandidateView(session=self.session)
|
||||
inbox_data=await history.attach_application_history(inbox_data)
|
||||
manual_upload_data=await history.attach_application_history(manual_upload_data)
|
||||
counts=serialize_pipeline_counts(
|
||||
await Inbox.count_by_status(self.session,job_post_id=job_post_id,search=search),
|
||||
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id,search=search),
|
||||
await Inbox.count_by_status(self.session,job_post_id=job_post_id),
|
||||
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id),
|
||||
)
|
||||
return {
|
||||
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data},
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
-- 035_job_post_recruiter_ids.sql
|
||||
-- A job post can have more than one recruiter. current_recruiter_id stays the
|
||||
-- first / primary pointer so existing joins and filters keep working;
|
||||
-- current_recruiter_ids is the full JSONB list used by create / update / get.
|
||||
-- Applied at startup by alembic_setup.run_manual_sql(). Needed because prod
|
||||
-- boots with DB_AUTOGENERATE=false.
|
||||
|
||||
ALTER TABLE app.job_posts
|
||||
ADD COLUMN IF NOT EXISTS current_recruiter_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
UPDATE app.job_posts
|
||||
SET current_recruiter_ids = jsonb_build_array(current_recruiter_id::text)
|
||||
WHERE current_recruiter_id IS NOT NULL
|
||||
AND (
|
||||
current_recruiter_ids IS NULL
|
||||
OR current_recruiter_ids = '[]'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_posts_current_recruiter_ids
|
||||
ON app.job_posts USING GIN (current_recruiter_ids);
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
-- 036_candidate_source_links.sql
|
||||
-- Scored `candidates` rows point at the application that owns the CV text.
|
||||
-- Extract lives on inbox_messages.resume_text / manual_upload_candidate.full_text
|
||||
-- (so Inbox and CV Bank can fetch it). Do not duplicate that blob here.
|
||||
-- Drops resume_text if an earlier draft of this file added it.
|
||||
-- Applied at startup by alembic_setup.run_manual_sql().
|
||||
|
||||
ALTER TABLE app.candidates
|
||||
DROP COLUMN IF EXISTS resume_text;
|
||||
|
||||
ALTER TABLE app.candidates
|
||||
ADD COLUMN IF NOT EXISTS inbox_message_id UUID REFERENCES app.inbox_messages (id);
|
||||
|
||||
ALTER TABLE app.candidates
|
||||
ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_candidates_inbox_message_id
|
||||
ON app.candidates (inbox_message_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_candidates_manual_upload_candidate_id
|
||||
ON app.candidates (manual_upload_candidate_id);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
-- 037_requisition_period_jd_entity.sql
|
||||
-- Annexure A revision 2 added three fields the original 020 table never had:
|
||||
-- "If not permanent, specify the period From/To" on the position block, the
|
||||
-- mandatory "JD Available Yes/No" flag (distinct from the free-text
|
||||
-- job_description), and "Entity" alongside Employee Name/Department in the
|
||||
-- internal-recommendation block. Matches Requisition in
|
||||
-- backend/candidate_forms/models.py. Applied at startup by
|
||||
-- alembic_setup.run_manual_sql() — needed because prod boots with
|
||||
-- DB_AUTOGENERATE=false and never autogenerates new columns.
|
||||
|
||||
ALTER TABLE app.requisitions
|
||||
ADD COLUMN IF NOT EXISTS period_from date,
|
||||
ADD COLUMN IF NOT EXISTS period_to date,
|
||||
ADD COLUMN IF NOT EXISTS jd_available boolean,
|
||||
ADD COLUMN IF NOT EXISTS entity varchar;
|
||||
|
|
@ -209,9 +209,8 @@ async def system_admin_ids(session):
|
|||
async def job_recruiter_ids(session, job):
|
||||
"""Recruiters currently linked to the job post.
|
||||
|
||||
Uses the live pointer (current_recruiter_id), the JSON list
|
||||
(current_recruiter_ids), and open job_assignments rows with
|
||||
assignment_role=primary_recruiter.
|
||||
Uses the live pointer (current_recruiter_id) and open job_assignments
|
||||
rows with assignment_role=primary_recruiter.
|
||||
"""
|
||||
ids = set()
|
||||
if job is None:
|
||||
|
|
@ -219,10 +218,6 @@ async def job_recruiter_ids(session, job):
|
|||
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
||||
if uid is not None:
|
||||
ids.add(uid)
|
||||
for raw in getattr(job, "current_recruiter_ids", None) or []:
|
||||
extra = _as_uuid(raw)
|
||||
if extra is not None:
|
||||
ids.add(extra)
|
||||
from job.assignment.models import JobAssignments
|
||||
rows = await JobAssignments.fetch_by_job(
|
||||
session, job.id, current_only=True, assignment_role="primary_recruiter",
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ class Offers(SQLModel, table=True):
|
|||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ and how a system-dropped attempt is labelled.
|
|||
"""
|
||||
from job.candidate.serializers import (
|
||||
is_assigned_application,
|
||||
is_kept_application,
|
||||
rejection_reason,
|
||||
serialize_application_history,
|
||||
serialize_application_history_item,
|
||||
|
|
@ -23,7 +22,6 @@ def test_unassigned_inbox_is_not_a_reapplication():
|
|||
"match_status": "matched",
|
||||
}
|
||||
assert is_assigned_application(row) is False
|
||||
assert is_kept_application(row) is True
|
||||
assert rejection_reason(row) is None
|
||||
|
||||
|
||||
|
|
@ -50,30 +48,6 @@ def test_unreadable_cv_is_wrong_format():
|
|||
item = serialize_application_history_item(row)
|
||||
assert item["status"] == "WRONG_FORMAT"
|
||||
assert item["rejection_reason"] == "wrong_format"
|
||||
assert is_kept_application(item) is True
|
||||
|
||||
|
||||
def test_unreadable_cv_plus_later_mail_is_a_reapplication():
|
||||
"""Attached CVs count even when the first PDF had no extractable text."""
|
||||
later = {
|
||||
"source": "inbox",
|
||||
"message_id": "new",
|
||||
"job_post_id": None,
|
||||
"status": "CLOSED",
|
||||
"attachment": True,
|
||||
"match_status": "matched",
|
||||
}
|
||||
earlier = {
|
||||
"source": "inbox",
|
||||
"message_id": "old",
|
||||
"job_post_id": None,
|
||||
"status": "CLOSED",
|
||||
"attachment": True,
|
||||
"match_status": "no_text",
|
||||
}
|
||||
history = serialize_application_history("a@x.com", applications=[later, earlier])
|
||||
assert history["is_reapplicant"] is True
|
||||
assert [item["rejection_reason"] for item in history["applications"]] == [None, "wrong_format"]
|
||||
|
||||
|
||||
def test_body_only_mail_is_wrong_format():
|
||||
|
|
@ -86,27 +60,20 @@ def test_filtered_classifier_row_is_wrong_format():
|
|||
assert rejection_reason(row) == "wrong_format"
|
||||
history = serialize_application_history("a@x.com", applications=[row])
|
||||
assert history["is_reapplicant"] is False
|
||||
assert history["applications"] == []
|
||||
assert history["applications"][0]["status"] == "WRONG_FORMAT"
|
||||
|
||||
|
||||
def test_history_reapplicant_counts_two_unassigned_mails():
|
||||
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True, "match_status": "matched"}
|
||||
def test_history_reapplicant_needs_an_assigned_job():
|
||||
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True}
|
||||
assigned = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer", "status": "PENDING"}
|
||||
only_mail = serialize_application_history("a@x.com", applications=[unassigned])
|
||||
assert only_mail["is_reapplicant"] is False
|
||||
both = serialize_application_history("a@x.com", applications=[unassigned, dict(unassigned)])
|
||||
assert len(only_mail["applications"]) == 1
|
||||
both = serialize_application_history("a@x.com", applications=[unassigned, assigned])
|
||||
assert both["is_reapplicant"] is True
|
||||
assert len(both["applications"]) == 2
|
||||
|
||||
|
||||
def test_wrong_format_plus_one_mail_is_not_a_reapplication():
|
||||
mail = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True, "match_status": "matched"}
|
||||
dropped = {"source": "filtered", "job_post_id": None, "status": "WRONG_FORMAT"}
|
||||
history = serialize_application_history("a@x.com", applications=[mail, dropped])
|
||||
assert history["is_reapplicant"] is False
|
||||
assert len(history["applications"]) == 1
|
||||
assert history["applications"][0]["source"] == "inbox"
|
||||
|
||||
|
||||
def test_clicked_inbox_row_is_not_a_previous_application():
|
||||
"""List payloads use `source` for the To address, not 'inbox'."""
|
||||
pk = "11111111-1111-1111-1111-111111111111"
|
||||
|
|
|
|||
|
|
@ -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"]) == 6
|
||||
assert len(technical["criteria"]) == 5
|
||||
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"]] == [6, 5]
|
||||
assert [len(s["criteria"]) for s in cf["sections"]] == [13]
|
||||
assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5]
|
||||
assert [len(s["criteria"]) for s in cf["sections"]] == [5]
|
||||
|
||||
def test_stage_gate_vocabulary(self):
|
||||
assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"}
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
"""CV Bank list payload — ATS fields, suggested jobs, scored job."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from job.candidate.serializers import serialize_bank_candidate, serialize_bank_silver_medalist
|
||||
|
||||
|
||||
def _speculative(**overrides):
|
||||
row = SimpleNamespace(
|
||||
id="11111111-1111-1111-1111-111111111111",
|
||||
candidate_name="Ada Lovelace",
|
||||
candidate_email="ada@example.com",
|
||||
candidate_phone="",
|
||||
file_name="ada.pdf",
|
||||
file_path="https://s3/ada.pdf",
|
||||
linkedin_url=None,
|
||||
current_company="Acme",
|
||||
current_position="Backend Engineer",
|
||||
education="",
|
||||
skills=["Python"],
|
||||
years_experience=6,
|
||||
bank_reason="speculative",
|
||||
bank_expires_at=None,
|
||||
user_id=None,
|
||||
job_post_id=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(row, key, value)
|
||||
return row
|
||||
|
||||
|
||||
def test_speculative_leaves_ats_empty_for_the_list_join():
|
||||
payload = serialize_bank_candidate(_speculative())
|
||||
assert payload["bank_source"] == "speculative"
|
||||
assert payload["ai_score"] is None
|
||||
assert payload["suggested_job_post_ids"] == []
|
||||
assert payload["suggested_jobs"] == []
|
||||
assert payload["scored_job_post_id"] is None
|
||||
assert payload["message_id"] is None
|
||||
assert payload["current_position"] == "Backend Engineer"
|
||||
assert payload["years_experience"] == 6
|
||||
assert payload["city"] is None
|
||||
|
||||
|
||||
def test_speculative_keeps_assigned_job_id():
|
||||
job_id = "22222222-2222-2222-2222-222222222222"
|
||||
payload = serialize_bank_candidate(_speculative(job_post_id=job_id))
|
||||
assert payload["assigned_job_post_id"] == job_id
|
||||
|
||||
|
||||
def test_silver_exposes_suggested_jobs_and_inbox_score_path():
|
||||
payload = serialize_bank_silver_medalist({
|
||||
"inbox_id": 42,
|
||||
"message_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"name": "Grace Hopper",
|
||||
"email": "grace@example.com",
|
||||
"current_company": "Navy",
|
||||
"current_title": "Rear Admiral",
|
||||
"matched_keywords": ["COBOL"],
|
||||
"years_experience": 20,
|
||||
"ai_score": 88,
|
||||
"recommendation": "Strong Match",
|
||||
"assigned_job_post_id": "job-1",
|
||||
"last_job_post_id": "job-1",
|
||||
"last_job_title": "Principal Engineer",
|
||||
"suggested_job_post_ids": ["job-1", "job-2"],
|
||||
"user_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"created_at": "2026-08-01T10:00:00Z",
|
||||
})
|
||||
assert payload["bank_source"] == "silver_medalist"
|
||||
assert payload["ai_score"] == 88
|
||||
assert payload["message_id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
assert payload["assigned_job_post_id"] == "job-1"
|
||||
assert payload["scored_job_post_id"] == "job-1"
|
||||
assert payload["scored_job_title"] == "Principal Engineer"
|
||||
assert payload["suggested_job_post_ids"] == ["job-1", "job-2"]
|
||||
assert payload["suggested_jobs"] == []
|
||||
|
||||
|
||||
def test_silver_without_suggestions_stays_empty():
|
||||
payload = serialize_bank_silver_medalist({
|
||||
"inbox_id": 7,
|
||||
"name": "Sparse",
|
||||
"email": "s@example.com",
|
||||
"ai_score": 70,
|
||||
})
|
||||
assert payload["suggested_job_post_ids"] == []
|
||||
assert payload["message_id"] is None
|
||||
assert payload["assigned_job_post_id"] is None
|
||||
|
||||
|
||||
def test_years_from_inbox_free_text():
|
||||
from inbox.models import _years_from_text
|
||||
assert _years_from_text("5+ years") == 5
|
||||
assert _years_from_text("6") == 6
|
||||
assert _years_from_text(8) == 8
|
||||
assert _years_from_text(None) is None
|
||||
assert _years_from_text("") is None
|
||||
|
|
@ -181,19 +181,3 @@ def test_messy_model_city_is_clamped_to_canonical_before_persist():
|
|||
|
||||
def test_city_sentinel_is_dropped():
|
||||
assert parse({"city": "no city mentioned"})["city"] is None
|
||||
|
||||
|
||||
def test_candidate_name_is_kept_when_it_appears_on_the_resume():
|
||||
fields = parse({"candidate_name": "Ada Lovelace"})
|
||||
assert fields["candidate_name"] == "Ada Lovelace"
|
||||
|
||||
|
||||
def test_candidate_name_absent_from_resume_is_dropped():
|
||||
fields = parse({"candidate_name": "Someone Else"})
|
||||
assert fields["candidate_name"] == ""
|
||||
|
||||
|
||||
def test_candidate_name_email_and_sentinel_are_dropped():
|
||||
assert parse({"candidate_name": "ada@example.com"})["candidate_name"] == ""
|
||||
assert parse({"candidate_name": "no name mentioned"})["candidate_name"] == ""
|
||||
assert parse({})["candidate_name"] == ""
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from uuid import uuid4
|
|||
from g_sheet.scoring import serialize_form_ats
|
||||
from inbox.serializers import serialize_ats_result, serialize_inbox_rescan_run
|
||||
from job.candidate.plugins import candidate_completed_fields, candidate_failed_fields
|
||||
from job.candidate.serializers import serialize_candidate
|
||||
|
||||
|
||||
def test_failed_fields_omit_professional_summary():
|
||||
|
|
@ -30,38 +29,6 @@ def test_completed_fields_include_professional_summary():
|
|||
source = {"safe_name": "a.pdf", "file_path": None, "sha256": "x"}
|
||||
fields = candidate_completed_fields(source, result)
|
||||
assert fields["professional_summary"] == "Python backend specialist in Engineering."
|
||||
assert "resume_text" not in fields
|
||||
assert "inbox_message_id" not in fields
|
||||
assert "manual_upload_candidate_id" not in fields
|
||||
|
||||
|
||||
def test_completed_fields_link_inbox_and_manual_ids():
|
||||
result = SimpleNamespace(
|
||||
candidate_name="Ada",
|
||||
job_title="Engineer",
|
||||
current_company="Acme",
|
||||
years_experience=6,
|
||||
match_score=82,
|
||||
matched_keywords=["Python"],
|
||||
missing_keywords=["AWS"],
|
||||
summary_critique="Strong Python, missing cloud.",
|
||||
professional_summary="Python backend specialist in Engineering.",
|
||||
)
|
||||
inbox_id = uuid4()
|
||||
manual_id = uuid4()
|
||||
inbox_fields = candidate_completed_fields(
|
||||
{"safe_name": "a.pdf", "file_path": None, "sha256": "x", "inbox_message_id": inbox_id},
|
||||
result,
|
||||
)
|
||||
assert inbox_fields["inbox_message_id"] == inbox_id
|
||||
assert "manual_upload_candidate_id" not in inbox_fields
|
||||
assert "resume_text" not in inbox_fields
|
||||
manual_fields = candidate_completed_fields(
|
||||
{"safe_name": "a.pdf", "file_path": None, "sha256": "x", "manual_upload_candidate_id": manual_id},
|
||||
result,
|
||||
)
|
||||
assert manual_fields["manual_upload_candidate_id"] == manual_id
|
||||
assert "inbox_message_id" not in manual_fields
|
||||
|
||||
|
||||
def test_serialize_ats_result_includes_summary():
|
||||
|
|
@ -114,39 +81,3 @@ def test_serialize_inbox_rescan_run_includes_summaries():
|
|||
assert payload["summaries"] == [entry]
|
||||
assert payload["id"] == str(run_id)
|
||||
assert payload["done_count"] == 1
|
||||
|
||||
|
||||
def test_serialize_candidate_links_source_tables_not_resume_text():
|
||||
inbox_id = uuid4()
|
||||
row = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
job_id=uuid4(),
|
||||
source="inbox",
|
||||
filename="a.pdf",
|
||||
file_path=None,
|
||||
content_sha256="x",
|
||||
candidate_email="ada@example.com",
|
||||
candidate_name="Ada",
|
||||
job_title="Engineer",
|
||||
current_company="Acme",
|
||||
years_experience=6,
|
||||
match_score=82,
|
||||
matched_keywords=["Python"],
|
||||
missing_keywords=["AWS"],
|
||||
summary_critique="Strong Python.",
|
||||
professional_summary="Python backend in Engineering.",
|
||||
linkedin_url=None,
|
||||
inbox_message_id=inbox_id,
|
||||
manual_upload_candidate_id=None,
|
||||
status="completed",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
model="gpt-test",
|
||||
created_by=uuid4(),
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
payload = serialize_candidate(row)
|
||||
assert payload["inbox_message_id"] == str(inbox_id)
|
||||
assert payload["manual_upload_candidate_id"] is None
|
||||
assert "resume_text" not in payload
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
"""One-off: rewrite inbox_messages.city to a proper city name.
|
||||
|
||||
Uses backend/global_cities.py as the city list (every country, not Pakistan-only).
|
||||
Standalone: does not import the app. Reads backend/.env for DB settings.
|
||||
|
||||
python fix_inbox_cities.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
from global_cities import CITY_BY_KEY, CITY_RE # noqa: E402
|
||||
|
||||
# Localities that do not contain the city name (Shahrah-e-Faisal, Malir, …).
|
||||
ALIASES = {
|
||||
"malir": "Karachi",
|
||||
"clifton": "Karachi",
|
||||
"korangi": "Karachi",
|
||||
"landhi": "Karachi",
|
||||
"pechs": "Karachi",
|
||||
"saddar": "Karachi",
|
||||
"lyari": "Karachi",
|
||||
"orangi": "Karachi",
|
||||
"nazimabad": "Karachi",
|
||||
"north nazimabad": "Karachi",
|
||||
"gulshan": "Karachi",
|
||||
"gulshan e iqbal": "Karachi",
|
||||
"gulistan e jauhar": "Karachi",
|
||||
"jauhar": "Karachi",
|
||||
"shah faisal": "Karachi",
|
||||
"shah re faisal": "Karachi",
|
||||
"shah rae faisal": "Karachi",
|
||||
"shahrah e faisal": "Karachi",
|
||||
"shahrah faisal": "Karachi",
|
||||
"shahrae faisal": "Karachi",
|
||||
"defence": "Karachi",
|
||||
"johar town": "Lahore",
|
||||
"model town": "Lahore",
|
||||
"gulberg": "Lahore",
|
||||
"township": "Lahore",
|
||||
"blue area": "Islamabad",
|
||||
}
|
||||
DROP = {
|
||||
"dha", "cantt", "cantonment", "cant", "phase", "sector", "area", "district",
|
||||
"tehsil", "malir", "gulberg", "clifton", "defence",
|
||||
}
|
||||
SECTOR_RE = re.compile(r"^(?:[a-z]-?\d+[a-z]?|\d+[a-z]?)$", re.I)
|
||||
SENTINELS = {"", "none", "null", "n/a", "-", "na", "n.a.", "n.a"}
|
||||
|
||||
|
||||
def canonical_city(text):
|
||||
raw = (text or "").strip()
|
||||
if not raw or raw.lower() in SENTINELS:
|
||||
return None
|
||||
known = CITY_BY_KEY.get(raw.lower())
|
||||
if known:
|
||||
return known
|
||||
cleaned = re.sub(r"[()\[\]{}]", " ", raw)
|
||||
cleaned = re.sub(r"[,/;|]+", " ", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
known = CITY_BY_KEY.get(cleaned.lower())
|
||||
if known:
|
||||
return known
|
||||
lowered = cleaned.lower()
|
||||
match = CITY_RE.search(lowered)
|
||||
if match:
|
||||
return CITY_BY_KEY[match.group(0)]
|
||||
hyphen_fold = re.sub(r"[-]+", " ", lowered)
|
||||
hyphen_fold = re.sub(r"\s+", " ", hyphen_fold).strip()
|
||||
for alias, city in sorted(ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
if alias in hyphen_fold:
|
||||
return city
|
||||
leftover = []
|
||||
for token in cleaned.split():
|
||||
lowered_token = token.lower()
|
||||
if lowered_token in DROP or SECTOR_RE.fullmatch(token):
|
||||
continue
|
||||
leftover.append(token)
|
||||
if leftover:
|
||||
known = CITY_BY_KEY.get(" ".join(leftover).lower())
|
||||
if known:
|
||||
return known
|
||||
return None
|
||||
|
||||
|
||||
def load_env():
|
||||
path = ROOT / "backend" / ".env"
|
||||
out = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
out[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
def rewrite_table(cur, table):
|
||||
cur.execute(
|
||||
f"SELECT id, city FROM {table} WHERE city IS NOT NULL AND btrim(city) <> ''"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
updated = 0
|
||||
skipped = 0
|
||||
unchanged = 0
|
||||
samples = []
|
||||
unmatched = []
|
||||
for record_id, city in rows:
|
||||
new = canonical_city(city)
|
||||
if new is None:
|
||||
cur.execute(f"UPDATE {table} SET city = NULL WHERE id = %s", (record_id,))
|
||||
skipped += 1
|
||||
if len(unmatched) < 30:
|
||||
unmatched.append(city)
|
||||
continue
|
||||
if new == city:
|
||||
unchanged += 1
|
||||
continue
|
||||
cur.execute(f"UPDATE {table} SET city = %s WHERE id = %s", (new, record_id))
|
||||
updated += 1
|
||||
if len(samples) < 20:
|
||||
samples.append((city, new))
|
||||
print(f"{table}: read={len(rows)} updated={updated} already_ok={unchanged} cleared={skipped}")
|
||||
for old, new in samples:
|
||||
print(f" {old!r} -> {new!r}")
|
||||
for city in unmatched:
|
||||
print(f" cleared {city!r}")
|
||||
|
||||
|
||||
def dropdown_cities(cur):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT city FROM inbox_messages
|
||||
WHERE city IS NOT NULL AND btrim(city) <> '' AND attachment = true
|
||||
UNION
|
||||
SELECT city FROM form_data
|
||||
WHERE city IS NOT NULL AND btrim(city) <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
|
||||
|
||||
def main():
|
||||
env = {**os.environ, **load_env()}
|
||||
kwargs = dict(
|
||||
host=env.get("DB_HOST", "localhost"),
|
||||
port=int(env.get("DB_PORT") or 5432),
|
||||
dbname=env.get("DB_NAME", "hrms"),
|
||||
user=env.get("DB_USERNAME", "postgres"),
|
||||
password=env.get("DB_PASSWORD", ""),
|
||||
options="-c search_path=app,public",
|
||||
)
|
||||
sslmode = (env.get("DB_SSLMODE") or "").strip()
|
||||
if sslmode:
|
||||
kwargs["sslmode"] = sslmode
|
||||
conn = psycopg2.connect(**kwargs)
|
||||
conn.autocommit = False
|
||||
print(f"db={kwargs['user']}@{kwargs['host']}:{kwargs['port']}/{kwargs['dbname']}")
|
||||
print(f"cities={len(CITY_BY_KEY)}")
|
||||
cur = conn.cursor()
|
||||
rewrite_table(cur, "inbox_messages")
|
||||
rewrite_table(cur, "form_data")
|
||||
conn.commit()
|
||||
names = dropdown_cities(cur)
|
||||
print(f"inbox city filter ({len(names)}):")
|
||||
for name in names:
|
||||
print(f" {name}")
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Candidate browse queue — unique ids, neighbors, profile paths.
|
||||
*
|
||||
* node candidate-browse.test.mjs
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { uniqueBrowseEntries, neighborsOf, candidatePath } from './src/lib/candidateBrowse.js'
|
||||
|
||||
const rows = [
|
||||
{ userId: 'a', name: 'Ada', stage: 'Interview', jobTitle: 'Backend' },
|
||||
{ user_id: 'a', name: 'Ada duplicate application' },
|
||||
{ userId: '', name: 'Skipped' },
|
||||
{ userId: 'b', email: 'b@example.com' },
|
||||
{ userId: 'c', name: 'Chris', job_title: 'Design' },
|
||||
]
|
||||
|
||||
const entries = uniqueBrowseEntries(rows)
|
||||
assert.deepEqual(entries.map((row) => row.userId), ['a', 'b', 'c'])
|
||||
assert.equal(entries[0].name, 'Ada')
|
||||
assert.equal(entries[0].stage, 'Interview')
|
||||
assert.equal(entries[1].name, 'b@example.com')
|
||||
assert.equal(entries[2].jobTitle, 'Design')
|
||||
|
||||
const mid = neighborsOf(entries, 'b')
|
||||
assert.equal(mid.index, 1)
|
||||
assert.equal(mid.total, 3)
|
||||
assert.equal(mid.prev.userId, 'a')
|
||||
assert.equal(mid.next.userId, 'c')
|
||||
|
||||
const first = neighborsOf(entries, 'a')
|
||||
assert.equal(first.prev, null)
|
||||
assert.equal(first.next.userId, 'b')
|
||||
|
||||
const missing = neighborsOf(entries, 'z')
|
||||
assert.equal(missing.index, -1)
|
||||
assert.equal(missing.prev, null)
|
||||
assert.equal(missing.next, null)
|
||||
|
||||
assert.equal(candidatePath('user/1'), '/candidate/user%2F1')
|
||||
assert.equal(candidatePath('abc', 'Resume'), '/candidate/abc?tab=Resume')
|
||||
|
||||
console.log('All candidate browse checks passed')
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
/* 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() }
|
||||
|
|
@ -72,18 +72,9 @@ const rejected = toApplicationListView({
|
|||
created_at: '2026-09-01T10:00:00Z',
|
||||
})
|
||||
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
|
||||
ok('CLOSED shows as CLOSED', toApplicationListView({
|
||||
ok('CLOSED also reads as Rejected', toApplicationListView({
|
||||
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
|
||||
}).stage === 'CLOSED')
|
||||
|
||||
const formRow = toApplicationListView({
|
||||
form_data_id: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
name: 'Form Applicant',
|
||||
source: 'Form',
|
||||
job_title: 'Brand Manager',
|
||||
})
|
||||
ok('form row key', formRow.id === 'form:ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||
ok('form with no pipeline status has no stage', formRow.stage == null)
|
||||
}).stage === 'Rejected')
|
||||
|
||||
const hired = toApplicationListView({
|
||||
inbox_id: 44,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* CV Bank mapper — speculative vs silver medalist, ATS score, suggested jobs,
|
||||
* and the per-row job the last ATS ran against.
|
||||
* CV Bank mapper — the two populations, and the two numbers that must not be
|
||||
* confused (free rank_score vs paid ai_score).
|
||||
*
|
||||
* node cvbank.test.mjs
|
||||
*/
|
||||
|
|
@ -56,6 +56,7 @@ const speculative = toBankRowView({
|
|||
years_experience: 6,
|
||||
ai_score: null,
|
||||
recommendation: null,
|
||||
rank_score: null,
|
||||
bank_reason: 'speculative',
|
||||
bank_expires_at: '2028-09-03T00:00:00Z',
|
||||
created_at: '2026-09-03T10:00:00Z',
|
||||
|
|
@ -67,13 +68,9 @@ ok('source label is human', speculative.sourceLabel === 'Speculative')
|
|||
ok('speculative rows are removable stored CVs', speculative.isStoredCv === true)
|
||||
ok('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker')
|
||||
ok('years is numeric', speculative.years === 6)
|
||||
ok('role comes through', speculative.title === 'Backend Engineer')
|
||||
ok('company comes through', speculative.company === 'Acme')
|
||||
ok('an unscored CV has no ATS score', speculative.aiScore === null)
|
||||
ok('and no invented band', speculative.recommendation === null)
|
||||
ok('speculative rows can run bank ATS', speculative.canRunAts === true)
|
||||
ok('no suggestions stays an empty list', Array.isArray(speculative.suggestedJobs) && speculative.suggestedJobs.length === 0)
|
||||
ok('no scored job until ATS runs', speculative.scoredJobPostId === null)
|
||||
ok('and no rank until a job is picked', speculative.rankScore === null)
|
||||
ok('expiry parses to a Date', speculative.expiresAt instanceof Date)
|
||||
ok('added parses to a Date', speculative.added instanceof Date)
|
||||
|
||||
|
|
@ -92,12 +89,6 @@ const silver = toBankRowView({
|
|||
ai_score: 88,
|
||||
recommendation: 'Strong Match',
|
||||
last_job_title: 'Principal Engineer',
|
||||
assigned_job_post_id: 'job-assigned',
|
||||
assigned_job_title: 'Principal Engineer',
|
||||
scored_job_post_id: 'job-assigned',
|
||||
scored_job_title: 'Principal Engineer',
|
||||
suggested_jobs: [{ id: 'job-a', title: 'Compiler Engineer' }, { id: 'job-b', title: 'Systems Lead' }],
|
||||
message_id: 'msg-1',
|
||||
user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
created_at: '2026-08-01T10:00:00Z',
|
||||
})
|
||||
|
|
@ -109,39 +100,25 @@ ok('paid ATS score survives', silver.aiScore === 88)
|
|||
ok('band survives', silver.recommendation === 'Strong Match')
|
||||
ok('the job they were rejected from is kept for context', silver.lastJobTitle === 'Principal Engineer')
|
||||
ok('userId is kept so the row can open a real profile', silver.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
ok('assigned job is shown on the picker', silver.assignedJobPostId === 'job-assigned' && silver.scoredJobTitle === 'Principal Engineer')
|
||||
ok('suggested job titles come through', silver.suggestedJobs.map((j) => j.title).join(',') === 'Compiler Engineer,Systems Lead')
|
||||
ok('inbox message lets silver Run ATS via score_inbox', silver.canRunAts === true && silver.messageId === 'msg-1')
|
||||
|
||||
const silverNoInbox = toBankRowView({
|
||||
id: 'app:99',
|
||||
record_id: '99',
|
||||
bank_source: 'silver_medalist',
|
||||
name: 'No Inbox',
|
||||
ai_score: 70,
|
||||
})
|
||||
ok('silver without a message cannot run bank ATS', silverNoInbox.canRunAts === false)
|
||||
/* --- rank_score is the free number, and separate from ai_score ----------- */
|
||||
|
||||
/* --- ATS score after scoring, independent of suggestions ----------------- */
|
||||
|
||||
const scoredBank = toBankRowView({
|
||||
const ranked = toBankRowView({
|
||||
id: 'bank:2',
|
||||
record_id: '2',
|
||||
bank_source: 'speculative',
|
||||
name: 'Scored',
|
||||
name: 'Ranked',
|
||||
skills: [],
|
||||
ai_score: 84,
|
||||
scored_job_post_id: 'job-9',
|
||||
scored_job_title: 'Backend Engineer',
|
||||
rank_score: 72,
|
||||
ai_score: null,
|
||||
})
|
||||
ok('ATS score maps onto the bank row', scoredBank.aiScore === 84)
|
||||
ok('Pick a job can restore the scored job on load', scoredBank.scoredJobPostId === 'job-9' && scoredBank.scoredJobTitle === 'Backend Engineer')
|
||||
ok('rank_score maps without becoming an ATS score', ranked.rankScore === 72 && ranked.aiScore === null)
|
||||
|
||||
const suggestedFromIds = toBankRowView({
|
||||
const bothNumbers = toBankRowView({
|
||||
id: 'app:3', record_id: '3', bank_source: 'silver_medalist',
|
||||
name: 'Ids only', suggested_job_post_ids: ['aaa', 'bbb'],
|
||||
name: 'Both', rank_score: 61, ai_score: 84,
|
||||
})
|
||||
ok('suggested_job_post_ids hydrate when titles were not resolved', suggestedFromIds.suggestedJobs.map((j) => j.id).join(',') === 'aaa,bbb')
|
||||
ok('a row can carry both numbers independently', bothNumbers.rankScore === 61 && bothNumbers.aiScore === 84)
|
||||
|
||||
/* --- absent values stay absent ------------------------------------------- */
|
||||
|
||||
|
|
@ -156,7 +133,6 @@ ok('no skills is an empty array, not null', Array.isArray(sparse.skills) && spar
|
|||
ok('unknown years stays null, never 0', sparse.years === null)
|
||||
ok('no expiry stays null', sparse.expiresAt === null)
|
||||
ok('unknown source defaults to speculative', sparse.source === 'speculative')
|
||||
ok('empty suggested-jobs cell stays empty', sparse.suggestedJobs.length === 0)
|
||||
|
||||
const zeroYears = toBankRowView({
|
||||
id: 'bank:5', record_id: '5', bank_source: 'speculative',
|
||||
|
|
@ -166,7 +142,7 @@ ok('0 years is a real value and must not collapse to null', zeroYears.years ===
|
|||
|
||||
const derivedBand = toBankRowView({
|
||||
id: 'app:6', record_id: '6', bank_source: 'silver_medalist',
|
||||
name: 'Derived', ai_score: 70, message_id: 'm',
|
||||
name: 'Derived', ai_score: 70,
|
||||
})
|
||||
ok('a score without a band derives one', derivedBand.recommendation === 'Potential Match')
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@
|
|||
"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 && node candidate-browse.test.mjs"
|
||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
/**
|
||||
* Reapplied chip — two kept applications count, even with no job assigned.
|
||||
*
|
||||
* node reapplicant.test.mjs
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import esbuild from 'esbuild'
|
||||
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'tf-reapp-'))
|
||||
const outFile = join(outDir, 'reapplicant.mjs')
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/components/ReapplicantHistory.jsx'],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
jsx: 'automatic',
|
||||
logLevel: 'error',
|
||||
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
|
||||
})
|
||||
|
||||
const { isReapplicant, candidateApplicationsOf, hrefForPreviousApplication } = await import(pathToFileURL(outFile).href)
|
||||
|
||||
let failed = 0
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) {
|
||||
console.log(`ok ${name}`)
|
||||
} else {
|
||||
failed += 1
|
||||
console.log(`FAIL ${name}`)
|
||||
if (extra) console.log(` ${extra}`)
|
||||
}
|
||||
}
|
||||
|
||||
const current = {
|
||||
id: 'inbox-2',
|
||||
inboxId: 'inbox-2',
|
||||
previousApplications: [
|
||||
{
|
||||
source: 'inbox',
|
||||
inbox_id: 'inbox-2',
|
||||
message_id: 'inbox-2',
|
||||
job_post_id: null,
|
||||
job_title: null,
|
||||
status: 'CLOSED',
|
||||
},
|
||||
{
|
||||
source: 'inbox',
|
||||
inbox_id: 'inbox-1',
|
||||
message_id: 'inbox-1',
|
||||
job_post_id: null,
|
||||
job_title: null,
|
||||
status: 'CLOSED',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ok('two unassigned emails still count as reapplied', isReapplicant(current) === true)
|
||||
|
||||
ok('a single application is not reapplied', isReapplicant({
|
||||
id: 'inbox-1',
|
||||
previousApplications: [{
|
||||
source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null,
|
||||
}],
|
||||
}) === false)
|
||||
|
||||
ok('a wrong-format drop plus one real mail is not reapplied', isReapplicant({
|
||||
id: 'inbox-1',
|
||||
previousApplications: [
|
||||
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED', attachment: true },
|
||||
{ source: 'filtered', message_id: 'drop-1', status: 'WRONG_FORMAT', rejection_reason: 'wrong_format' },
|
||||
],
|
||||
}) === false)
|
||||
|
||||
ok('an unreadable prior CV is not a reapplication', isReapplicant({
|
||||
id: 'inbox-2',
|
||||
previousApplications: [
|
||||
{ source: 'inbox', inbox_id: 'inbox-2', message_id: 'inbox-2', job_post_id: null, status: 'CLOSED', attachment: true, match_status: 'matched' },
|
||||
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'WRONG_FORMAT', rejection_reason: 'wrong_format', attachment: true, match_status: 'no_text' },
|
||||
],
|
||||
}) === false)
|
||||
|
||||
ok('body-only mail plus one real mail is not reapplied', isReapplicant({
|
||||
id: 'inbox-1',
|
||||
previousApplications: [
|
||||
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED', attachment: true },
|
||||
{ source: 'inbox', inbox_id: 'inbox-0', message_id: 'inbox-0', job_post_id: null, status: 'WRONG_FORMAT', rejection_reason: 'wrong_format', attachment: false },
|
||||
],
|
||||
}) === false)
|
||||
|
||||
ok('an assigned prior application still counts', isReapplicant({
|
||||
id: 'inbox-2',
|
||||
previousApplications: [
|
||||
{ source: 'inbox', inbox_id: 'inbox-2', message_id: 'inbox-2', job_post_id: null },
|
||||
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: 'job-1', job_title: 'Analyst' },
|
||||
],
|
||||
}) === true)
|
||||
|
||||
ok('wrong-format rows are omitted from the highlighted list', candidateApplicationsOf({
|
||||
id: 'inbox-1',
|
||||
previousApplications: [
|
||||
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED' },
|
||||
{ source: 'filtered', message_id: 'drop-1', status: 'WRONG_FORMAT', rejection_reason: 'wrong_format' },
|
||||
],
|
||||
}).length === 1)
|
||||
|
||||
ok('email history still opens the inbox applicant', hrefForPreviousApplication({
|
||||
source: 'inbox', message_id: 'msg-1',
|
||||
}) === '/inbox?open=msg-1&kind=email')
|
||||
|
||||
ok('form history still opens the inbox form applicant', hrefForPreviousApplication({
|
||||
source: 'form', form_data_id: 'form-1',
|
||||
}) === '/inbox?open=form-1&kind=form')
|
||||
|
||||
ok('upload history opens the candidate profile', hrefForPreviousApplication({
|
||||
source: 'manual', user_id: 'user-1', manual_upload_candidate_id: 'm-1',
|
||||
}) === '/candidate/user-1')
|
||||
|
||||
ok('upload without its own user id uses the open row', hrefForPreviousApplication(
|
||||
{ source: 'manual', manual_upload_candidate_id: 'm-1' },
|
||||
{ userId: 'user-9' },
|
||||
) === '/candidate/user-9')
|
||||
|
||||
ok('upload never falls through to matching', hrefForPreviousApplication({
|
||||
source: 'manual', manual_upload_candidate_id: 'm-1',
|
||||
}) == null)
|
||||
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
|
||||
if (failed) {
|
||||
console.log(`\n${failed} check(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nAll reapplicant checks passed')
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { lazy } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AuthProvider from './auth/AuthProvider'
|
||||
import RequireAuth from './auth/RequireAuth'
|
||||
|
|
@ -47,14 +47,6 @@ 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>
|
||||
|
|
@ -103,14 +95,6 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidates/:userId"
|
||||
element={
|
||||
<RequireAuth permission="candidates.view">
|
||||
<LegacyCandidateRedirect />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
|
|||
/** Board column order used by the pipeline page. Rejected is last so callers
|
||||
* that drop outcomes can slice it off without re-sorting. */
|
||||
const BOARD_STAGE_ORDER = [
|
||||
'CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||
]
|
||||
|
||||
/**
|
||||
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
||||
* columns. Same mapping the board uses, so CLOSED stays CLOSED and only
|
||||
* REJECTED is Rejected. ONHOLD / APPROVED keep their own columns.
|
||||
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
|
||||
* APPROVED keep their own columns rather than folding into Screening / Hired.
|
||||
*/
|
||||
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
||||
const folded = toStageCounts(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { toDate } from '../lib/format'
|
|||
|
||||
Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
|
||||
/managers/fetch. Neither needs rbac_users.view. The current pointers also
|
||||
live on job_posts (current_recruiter_ids / current_recruiter_id, hiring_manager_id) and PATCH
|
||||
live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH
|
||||
/jobs/update is the Jobs-screen write path.
|
||||
============================================================ */
|
||||
|
||||
|
|
|
|||
|
|
@ -59,8 +59,9 @@ export function uploadToCvBank(file) {
|
|||
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
|
||||
* speculative uploads with no job, and rejected applicants who scored well.
|
||||
*
|
||||
* `jobPostId` is unused by the CV Bank screen. The suggestions endpoint still
|
||||
* passes it to attach rank_score for notifications on job create.
|
||||
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword
|
||||
* overlap against that job) and sorts by it — the "a role just opened, who do
|
||||
* we already have" view.
|
||||
*/
|
||||
export function listCvBank({
|
||||
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
|
||||
|
|
@ -159,21 +160,6 @@ export function scoreInbox(jobId, messageIds) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-score one candidate from stored professional_summary (no PDF re-read).
|
||||
* Needs candidates.create. Pass the profile userId, or an inbox / manual id.
|
||||
*/
|
||||
export function rerunAts({ userId, inboxMessageId, manualUploadCandidateId } = {}) {
|
||||
return request('/candidate/ats-rerun', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
user_id: userId || null,
|
||||
inbox_message_id: inboxMessageId || null,
|
||||
manual_upload_candidate_id: manualUploadCandidateId || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared snake_case → camelCase view-model mapper for candidate rows, so the
|
||||
* three candidate screens agree on field names. Fields the backend does not
|
||||
|
|
@ -252,12 +238,6 @@ function statusKey(value) {
|
|||
return String(value).toUpperCase()
|
||||
}
|
||||
|
||||
function listStageOf(status) {
|
||||
const key = statusKey(status)
|
||||
if (!key) return null
|
||||
return STAGE_FROM_STATUS[key] ?? 'Shortlist'
|
||||
}
|
||||
|
||||
function bandOf(score, recommendation) {
|
||||
if (recommendation) return recommendation
|
||||
if (score == null || !Number.isFinite(Number(score))) return null
|
||||
|
|
@ -281,15 +261,12 @@ export function toApplicationListView(row) {
|
|||
const rawScore = row.ai_score ?? row.match_score
|
||||
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
|
||||
const score = Number.isFinite(aiScore) ? aiScore : null
|
||||
const id = row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: (row.form_data_id
|
||||
? `form:${row.form_data_id}`
|
||||
: String(row.user_id || row.id || name)))
|
||||
return {
|
||||
id,
|
||||
id: row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: String(row.user_id || row.id || name)),
|
||||
userId: row.user_id || null,
|
||||
name,
|
||||
email: row.email ?? null,
|
||||
|
|
@ -297,15 +274,11 @@ export function toApplicationListView(row) {
|
|||
jobTitle,
|
||||
recruiter: row.recruiter || null,
|
||||
applicationStatus: status || null,
|
||||
stage: listStageOf(row.application_status ?? row.stage),
|
||||
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
|
||||
source: row.source || null,
|
||||
aiScore: score,
|
||||
recommendation: bandOf(score, row.recommendation || null),
|
||||
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
|
||||
assignedJobPostId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
suggestedJobPostIds: Array.isArray(row.suggested_job_post_ids)
|
||||
? row.suggested_job_post_ids.map(String)
|
||||
: [],
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
|
|
@ -316,50 +289,30 @@ export const BANK_SOURCE_LABELS = {
|
|||
silver_medalist: 'Silver medalist',
|
||||
}
|
||||
|
||||
function asJobList(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
const out = []
|
||||
for (const item of value) {
|
||||
if (item && typeof item === 'object') {
|
||||
const id = item.id != null ? String(item.id) : ''
|
||||
if (id) out.push({ id, title: item.title || '' })
|
||||
continue
|
||||
}
|
||||
if (item != null && item !== '') out.push({ id: String(item), title: '' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /candidate/cv-bank/fetch row -> the CV Bank table.
|
||||
*
|
||||
* `aiScore` is a real paid ATS score and only exists once someone ran one.
|
||||
* Suggested jobs come from inbox silver-medalist payloads; speculative rows
|
||||
* typically have none. `scoredJobPostId` is the job the last ATS ran against.
|
||||
* Two numbers that must never be confused: `aiScore` is a real paid ATS score
|
||||
* and only exists once someone ran one; `rankScore` is free keyword overlap
|
||||
* against whichever job is selected. The screen renders them differently on
|
||||
* purpose.
|
||||
*/
|
||||
export function toBankRowView(row) {
|
||||
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
|
||||
const aiScore = Number.isFinite(score) ? score : null
|
||||
const rank = row.rank_score == null || row.rank_score === '' ? null : Number(row.rank_score)
|
||||
const years = row.years_experience == null || row.years_experience === ''
|
||||
? null
|
||||
: Number(row.years_experience)
|
||||
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null
|
||||
const suggestedJobs = row.suggested_jobs?.length
|
||||
? asJobList(row.suggested_jobs)
|
||||
: asJobList(row.suggested_job_post_ids)
|
||||
const assignedJobPostId = row.assigned_job_post_id ? String(row.assigned_job_post_id) : null
|
||||
const scoredJobPostId = row.scored_job_post_id
|
||||
? String(row.scored_job_post_id)
|
||||
: assignedJobPostId
|
||||
const isStoredCv = row.bank_source !== 'silver_medalist'
|
||||
return {
|
||||
id: String(row.id || ''),
|
||||
recordId: row.record_id != null ? String(row.record_id) : null,
|
||||
source: row.bank_source || 'speculative',
|
||||
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
|
||||
// A silver medalist is read live from their application, so removing or
|
||||
// re-scoring them via the bank score endpoint is not the bank's call.
|
||||
isStoredCv,
|
||||
// re-scoring them is not the bank's call to make.
|
||||
isStoredCv: row.bank_source !== 'silver_medalist',
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email ?? null,
|
||||
phone: row.phone ?? null,
|
||||
|
|
@ -369,18 +322,11 @@ export function toBankRowView(row) {
|
|||
company: row.current_company ?? null,
|
||||
title: row.current_position ?? null,
|
||||
education: row.education ?? null,
|
||||
city: row.city ?? null,
|
||||
skills: Array.isArray(row.skills) ? row.skills : [],
|
||||
years: Number.isFinite(years) ? years : null,
|
||||
aiScore,
|
||||
recommendation: bandOf(aiScore, row.recommendation || null),
|
||||
suggestedJobs,
|
||||
scoredJobPostId,
|
||||
scoredJobTitle: row.scored_job_title || row.assigned_job_title || null,
|
||||
assignedJobPostId,
|
||||
assignedJobTitle: row.assigned_job_title || null,
|
||||
messageId: row.message_id ? String(row.message_id) : null,
|
||||
canRunAts: isStoredCv || Boolean(row.message_id),
|
||||
rankScore: Number.isFinite(rank) ? rank : null,
|
||||
lastJobTitle: row.last_job_title ?? null,
|
||||
bankReason: row.bank_reason ?? null,
|
||||
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,
|
||||
|
|
@ -412,9 +358,9 @@ export function expiryLabel(expiresAt, now = new Date()) {
|
|||
* `search` is an ilike over users.name / users.email only — it does NOT reach
|
||||
* the résumé text or the suggested job titles.
|
||||
*/
|
||||
export function list({ search, limit, offset, assignedJobPostId, assignment } = {}) {
|
||||
export function list({ search, limit, offset, assignedJobPostId } = {}) {
|
||||
return request('/candidate/fetch', {
|
||||
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId, assignment },
|
||||
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
HR Evaluation (form_type stays "cultural_fit"). Dual-key like assessments:
|
||||
exactly one of inbox_id / manual_upload_candidate_id.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function listMessages() {
|
|||
* `assigned` is tri-valued: omit for no filter, true for rows with an
|
||||
* assigned_job_post_id, false for the Job Matching queue.
|
||||
*/
|
||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, hasSuggestions, processingState, city, source, cityList, jobPostIds } = {}) {
|
||||
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) {
|
||||
return request('/inbox/all-applications', {
|
||||
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
|
||||
// to true = no filter), send false for the Unread tab only. buildUrl drops
|
||||
|
|
@ -29,11 +29,9 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
|
||||
// Same for `is_duplicate`: omit unless the Duplicates tab.
|
||||
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
|
||||
// `has_suggestions`: Suggested Match tab — rows with a suggest_job_post_id.
|
||||
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
|
||||
// processed, not application_status PROCESS).
|
||||
// `city`: optional comma-separated list. `source`: channel / platform label.
|
||||
// `job_post_ids`: optional comma-separated job post UUIDs (multi-select).
|
||||
// `city_list`: include merged distinct cities and sources on the same response.
|
||||
params: {
|
||||
search,
|
||||
|
|
@ -45,11 +43,9 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
assigned,
|
||||
is_duplicate: isDuplicate,
|
||||
no_suggestions: noSuggestions,
|
||||
has_suggestions: hasSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
source,
|
||||
job_post_ids: jobPostIds,
|
||||
city_list: cityList,
|
||||
},
|
||||
})
|
||||
|
|
@ -146,7 +142,7 @@ export function bulkSetRead(recordIds, read) {
|
|||
* Resolves to `{updated, read}`, where `updated` counts rows that actually
|
||||
* CHANGED state, so it is safe to show in a toast.
|
||||
*/
|
||||
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, hasSuggestions, processingState, city, source, jobPostIds } = {}) {
|
||||
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) {
|
||||
return request('/inbox/read-all', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
|
|
@ -157,11 +153,9 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
|
|||
assigned,
|
||||
is_duplicate: isDuplicate,
|
||||
no_suggestions: noSuggestions,
|
||||
has_suggestions: hasSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
source,
|
||||
job_post_ids: jobPostIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -174,14 +168,6 @@ export function assignJobPost(recordId, jobPostId) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Assign (or clear with null) the recruiter for one application. Requires inbox.edit. */
|
||||
export function assignRecruiter(recordId, recruiterId) {
|
||||
return request(`/inbox/${recordId}/assign-recruiter`, {
|
||||
method: 'PATCH',
|
||||
body: { recruiter_id: recruiterId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Re-queue the matching agent for one application. Requires inbox.edit. */
|
||||
export function rematch(recordId) {
|
||||
return request(`/inbox/${recordId}/match`, { method: 'POST' })
|
||||
|
|
|
|||
|
|
@ -47,12 +47,7 @@ export function toJobStatsView(row) {
|
|||
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—',
|
||||
requisitionStatus: row.requisition_status,
|
||||
recruiterId: row.current_recruiter_id || null,
|
||||
recruiterIds: Array.isArray(row.current_recruiter_ids)
|
||||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||||
: (row.current_recruiter_id ? [String(row.current_recruiter_id)] : []),
|
||||
recruiterName: (Array.isArray(row.recruiter_names) && row.recruiter_names.length
|
||||
? row.recruiter_names.filter(Boolean)
|
||||
: (row.recruiter_name ? [row.recruiter_name] : [])).join(', ') || null,
|
||||
recruiterName: row.recruiter_name || null,
|
||||
createdAt,
|
||||
daysOpen: daysOpen(createdAt),
|
||||
total: Number(row.total_applicants) || 0,
|
||||
|
|
|
|||
|
|
@ -64,28 +64,8 @@ function experienceLabel(min, max) {
|
|||
return `${min ?? max}+ years`
|
||||
}
|
||||
|
||||
function recruiterIdsFrom(row) {
|
||||
const ids = Array.isArray(row?.current_recruiter_ids)
|
||||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||||
: []
|
||||
if (ids.length) return ids
|
||||
return row?.current_recruiter_id ? [String(row.current_recruiter_id)] : []
|
||||
}
|
||||
|
||||
function recruiterNamesFrom(row) {
|
||||
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
|
||||
return row.recruiter_names.filter(Boolean)
|
||||
}
|
||||
if (Array.isArray(row?.recruiters) && row.recruiters.length) {
|
||||
return row.recruiters.map((r) => r?.name).filter(Boolean)
|
||||
}
|
||||
return row?.recruiter_name ? [row.recruiter_name] : []
|
||||
}
|
||||
|
||||
/** API row -> what the Jobs table and detail modal render. */
|
||||
export function toJobView(row) {
|
||||
const recruiterIds = recruiterIdsFrom(row)
|
||||
const recruiterNames = recruiterNamesFrom(row)
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
|
|
@ -96,10 +76,8 @@ export function toJobView(row) {
|
|||
platform: row.platform || null,
|
||||
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status,
|
||||
publishStatus: row.status,
|
||||
recruiter: recruiterNames.join(', ') || null,
|
||||
recruiterId: recruiterIds[0] || null,
|
||||
recruiterIds,
|
||||
recruiterNames,
|
||||
recruiter: row.recruiter_name,
|
||||
recruiterId: row.current_recruiter_id,
|
||||
hiringManager: row.hiring_manager_name,
|
||||
hiringManagerId: row.hiring_manager_id,
|
||||
createdByName: row.created_by_name,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import { toDate } from '../lib/format'
|
|||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||
*
|
||||
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default and
|
||||
* is shown as CLOSED. Only REJECTED reads as Rejected.
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
||||
* an application that did not progress — it reads as Rejected, same as the
|
||||
* board's Rejected column, not as Shortlist.
|
||||
*
|
||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||
* board — a card with no column is a candidate nobody sees.
|
||||
|
|
@ -33,7 +34,7 @@ export const STAGE_FROM_STATUS = {
|
|||
OFFER: 'Offer',
|
||||
APPROVED: 'Approved',
|
||||
HIRED: 'Hired',
|
||||
CLOSED: 'CLOSED',
|
||||
CLOSED: 'Rejected',
|
||||
REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +46,6 @@ export const STAGE_FROM_STATUS = {
|
|||
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
||||
*/
|
||||
export const STATUS_FROM_STAGE = {
|
||||
CLOSED: 'CLOSED',
|
||||
Shortlist: 'PENDING',
|
||||
Screening: 'SCREENING',
|
||||
'On Hold': 'ONHOLD',
|
||||
|
|
@ -81,9 +81,9 @@ export function changeStage({ inboxId, manualUploadId, toStage, changeReason })
|
|||
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
|
||||
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
|
||||
*/
|
||||
export function listApplications({ jobId, limit, offset, search } = {}) {
|
||||
export function listApplications({ jobId, limit, offset } = {}) {
|
||||
return request('/pipeline/candidates/fetch', {
|
||||
params: { job_post_id: jobId, limit, offset, search },
|
||||
params: { job_post_id: jobId, limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,9 @@ export function openUrl(key, { expiresIn } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
function asList(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
/** First comma-separated stored path — inbox_messages.file_path can list several. */
|
||||
export function firstKey(filePath) {
|
||||
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
|
||||
return (filePath || '').split(',')[0].trim() || null
|
||||
}
|
||||
|
||||
/** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */
|
||||
|
|
@ -45,17 +37,9 @@ export function canOpen(filePath) {
|
|||
/** First usable S3/http ref on a candidate or inbox payload. */
|
||||
export function resumeKeyFrom(item) {
|
||||
if (!item) return null
|
||||
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)
|
||||
const fromFiles = (item.files || []).map((f) => f.url).find(Boolean)
|
||||
if (fromFiles) return firstKey(fromFiles)
|
||||
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)
|
||||
const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean)
|
||||
if (fromDocs) return firstKey(fromDocs)
|
||||
return firstKey(item.file_path || item.filePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,11 @@ export function listFormDataSheets() {
|
|||
export function listFormData({
|
||||
sheet, search, offset = 0, limit, processing_state, is_duplicate,
|
||||
hasLinkedin, hasResume, city, source, assigned, no_suggestions,
|
||||
has_suggestions, job_post_ids,
|
||||
} = {}) {
|
||||
return request('/sheet/form-data/fetch', {
|
||||
params: {
|
||||
sheet, search, offset, limit, processing_state, is_duplicate,
|
||||
has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions,
|
||||
has_suggestions, job_post_ids,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -49,9 +47,9 @@ export function countFormData({ sheet } = {}) {
|
|||
* processing_state or is_duplicate: those two ARE the tabs, and passing them
|
||||
* would make every badge report the tab the user is already on.
|
||||
*/
|
||||
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned, job_post_ids } = {}) {
|
||||
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) {
|
||||
return request('/sheet/form-data/counts', {
|
||||
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, job_post_ids },
|
||||
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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)
|
||||
|
||||
|
|
@ -39,7 +38,7 @@ export default function AppLayout() {
|
|||
}, [location.pathname, setNavOpen])
|
||||
|
||||
return (
|
||||
<div id="app" className={candidateView ? 'candidate-workspace' : undefined}>
|
||||
<div id="app">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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 }) {
|
||||
|
|
@ -80,7 +79,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={() => openCandidateProfile(navigate, c.id, candidates.map((row) => ({ userId: row.id, name: row.name })))}>
|
||||
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
|
||||
<Avatar name={c.name} />
|
||||
<div>
|
||||
<div className="si-title">{c.name}</div>
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ 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 })}>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const SOURCE_LABEL = {
|
|||
}
|
||||
|
||||
const STAGE_BADGE = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
@ -66,7 +65,7 @@ export function candidateApplicationsOf(row) {
|
|||
if (self) items.push(self)
|
||||
}
|
||||
items.sort((a, b) => (appliedAtMs(b) ?? 0) - (appliedAtMs(a) ?? 0))
|
||||
return items.filter(isKeptAttempt)
|
||||
return items
|
||||
}
|
||||
|
||||
/** Applications other than the open row — used by the Reapplied chip. */
|
||||
|
|
@ -99,9 +98,6 @@ function syntheticCurrentApplication(row) {
|
|||
job_title: jobTitle,
|
||||
status: row.applicationStatus || row.processingState || row.status || null,
|
||||
applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null,
|
||||
attachment: row.hasAttachment ?? row.attachment ?? null,
|
||||
match_status: row.matchStatus || row.match_status || null,
|
||||
rejection_reason: row.rejectionReason || row.rejection_reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,25 +154,15 @@ function isSameApplication(item, currentIds) {
|
|||
].some((id) => id != null && id !== '' && currentIds.has(String(id)))
|
||||
}
|
||||
|
||||
function isWrongFormat(item) {
|
||||
if (!item) return true
|
||||
if (item.rejection_reason === 'wrong_format' || item.rejectionReason === 'wrong_format') return true
|
||||
if (String(item.status || '').toUpperCase() === 'WRONG_FORMAT') return true
|
||||
if (item.source === 'filtered') return true
|
||||
const match = String(item.match_status || item.matchStatus || '').trim().toLowerCase()
|
||||
if (match === 'no_text' || match === 'failed' || match === 'dlq') return true
|
||||
if (item.source === 'inbox' && item.attachment === false) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** A real prior attempt — unassigned inbox mail counts; a dropped PDF does not. */
|
||||
function isKeptAttempt(item) {
|
||||
return Boolean(item) && !isWrongFormat(item)
|
||||
function hasAssignedJob(item) {
|
||||
if (!item) return false
|
||||
if (item.job_post_id || item.jobPostId) return true
|
||||
return item.source === 'form' && Boolean(item.job_title || item.jobTitle)
|
||||
}
|
||||
|
||||
export function isReapplicant(row) {
|
||||
if (!row) return false
|
||||
return previousApplicationsOf(row).some(isKeptAttempt)
|
||||
return previousApplicationsOf(row).some(hasAssignedJob)
|
||||
}
|
||||
|
||||
export function previousApplicationsTip(row) {
|
||||
|
|
@ -191,7 +177,7 @@ export function previousApplicationsTip(row) {
|
|||
/** Compact chip for tables, kanban cards, and inbox rows. */
|
||||
export function ReappliedBadge({ row, className = '' }) {
|
||||
if (!isReapplicant(row)) return null
|
||||
const count = previousApplicationsOf(row).filter(isKeptAttempt).length
|
||||
const count = previousApplicationsOf(row).filter(hasAssignedJob).length
|
||||
return (
|
||||
<span
|
||||
className={`badge b-amber badge-plain ${className}`.trim()}
|
||||
|
|
@ -203,7 +189,7 @@ export function ReappliedBadge({ row, className = '' }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function hrefForPreviousApplication(item, row) {
|
||||
export function hrefForPreviousApplication(item) {
|
||||
if (!item) return null
|
||||
if (item.source === 'form' && item.form_data_id) {
|
||||
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||
|
|
@ -212,8 +198,10 @@ export function hrefForPreviousApplication(item, row) {
|
|||
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||
}
|
||||
if (item.source === 'manual') {
|
||||
const userId = item.user_id || item.userId || row?.userId || row?.user_id
|
||||
return userId ? `/candidate/${encodeURIComponent(userId)}` : null
|
||||
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||
if (item.manual_upload_candidate_id) {
|
||||
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
||||
}
|
||||
}
|
||||
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||
if (item.form_data_id) {
|
||||
|
|
@ -258,7 +246,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) {
|
|||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status, item)
|
||||
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||
const href = hrefForPreviousApplication(item, row)
|
||||
const href = hrefForPreviousApplication(item)
|
||||
const isCurrent = current.size > 0 && isSameApplication(item, current)
|
||||
const key = [
|
||||
item.source,
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
/* Session-scoped candidate queue so Previous / Next on the profile walks
|
||||
the same list the recruiter was looking at (Candidates table, Pipeline
|
||||
board, CV Bank, search), not an arbitrary server page. */
|
||||
|
||||
export const BROWSE_KEY = 'tf-candidate-browse'
|
||||
|
||||
export function uniqueBrowseEntries(rows = []) {
|
||||
const entries = []
|
||||
const seen = new Set()
|
||||
for (const row of rows) {
|
||||
if (!row) continue
|
||||
const userId = String(row.userId ?? row.user_id ?? '').trim()
|
||||
if (!userId || seen.has(userId)) continue
|
||||
seen.add(userId)
|
||||
entries.push({
|
||||
userId,
|
||||
name: String(row.name || row.email || 'Candidate'),
|
||||
stage: row.stage || null,
|
||||
jobTitle: row.jobTitle || row.job_title || null,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function neighborsOf(entries, userId) {
|
||||
const id = String(userId ?? '')
|
||||
const index = entries.findIndex((entry) => entry.userId === id)
|
||||
return {
|
||||
index,
|
||||
total: entries.length,
|
||||
prev: index > 0 ? entries[index - 1] : null,
|
||||
next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : null,
|
||||
current: index >= 0 ? entries[index] : null,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
export function candidatePath(userId, tab) {
|
||||
const path = `/candidate/${encodeURIComponent(userId)}`
|
||||
return tab ? `${path}?tab=${encodeURIComponent(tab)}` : path
|
||||
}
|
||||
|
||||
export function rememberCandidateBrowse(rows) {
|
||||
const entries = uniqueBrowseEntries(rows)
|
||||
try {
|
||||
sessionStorage.setItem(BROWSE_KEY, JSON.stringify({ entries }))
|
||||
} catch { /* private mode / quota — browsing still works for this click */ }
|
||||
return entries
|
||||
}
|
||||
|
||||
export function readCandidateBrowse() {
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(BROWSE_KEY) || 'null')
|
||||
return uniqueBrowseEntries(parsed?.entries || [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function openCandidateProfile(navigate, userId, rows, { replace = false, tab } = {}) {
|
||||
if (rows) rememberCandidateBrowse(rows)
|
||||
if (!userId) return
|
||||
navigate(candidatePath(userId, tab), { replace })
|
||||
}
|
||||
|
|
@ -107,7 +107,6 @@ 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 —
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { Icon } from '../ui/primitives'
|
||||
|
||||
export function CandidateBrowseNav({ browse, onBrowse }) {
|
||||
if (!browse || browse.index < 0 || browse.total < 1) return null
|
||||
const { index, total, prev, next } = browse
|
||||
return (
|
||||
<div className="cw-browse-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm cw-browse-arrow"
|
||||
disabled={!prev}
|
||||
aria-label="Previous candidate"
|
||||
title={prev ? `Previous: ${prev.name}` : 'No previous candidate'}
|
||||
onClick={() => prev && onBrowse(prev.userId)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</button>
|
||||
<span className="cw-browse-counter">{index + 1} of {total}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm cw-browse-arrow"
|
||||
disabled={!next}
|
||||
aria-label="Next candidate"
|
||||
title={next ? `Next: ${next.name}` : 'No next candidate'}
|
||||
onClick={() => next && onBrowse(next.userId)}
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
/* 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.
|
||||
/* 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.
|
||||
|
||||
Field and criterion labels are rendered from GET /forms/definitions — the
|
||||
backend is the single authority for the paper forms' exact wording. The
|
||||
|
|
@ -163,7 +161,7 @@ export default function CandidateFormsTab({ userId, live }) {
|
|||
}
|
||||
const segTabs = [
|
||||
{ key: 'interview_analysis', label: 'Interview Analysis' },
|
||||
{ key: 'cultural_fit', label: 'HR Evaluation' },
|
||||
{ key: 'cultural_fit', label: 'Cultural Fit' },
|
||||
...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []),
|
||||
]
|
||||
|
||||
|
|
@ -235,7 +233,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="HR Evaluation" value={summary.cultural_avg} />
|
||||
<ScoreTile label="Cultural Fit" value={summary.cultural_avg} />
|
||||
<ScoreTile
|
||||
label="Combined Overall"
|
||||
value={summary.combined_overall}
|
||||
|
|
@ -337,7 +335,7 @@ function RatingTable({ section, defs, ratings, onRate }) {
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Interview Analysis / HR Evaluation — data-driven off the definition's
|
||||
Interview Analysis / Cultural Fit — data-driven off the definition's
|
||||
sections; both types share this component. */
|
||||
|
||||
function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) {
|
||||
|
|
|
|||
|
|
@ -4,87 +4,20 @@
|
|||
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. */
|
||||
|
||||
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 { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
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 { useId, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
|
|
@ -19,8 +19,6 @@ 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'
|
||||
|
|
@ -29,7 +27,7 @@ import { companies, moneyK, pick } from '../data/seed'
|
|||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||
const KANBAN_ORDER = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
|
||||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||||
|
|
@ -86,7 +84,7 @@ function useCandidateDetail(userId) {
|
|||
* scorecard both land through the same refetch rather than through hand-patched
|
||||
* cache entries that could drift from the server's view.
|
||||
*/
|
||||
function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
|
||||
function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
return useMutation({
|
||||
|
|
@ -97,7 +95,7 @@ function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
|
|||
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
||||
onDone?.()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, error || 'Could not save. Please try again.'), 'error'),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -110,31 +108,18 @@ function useProfileWrite({ userId, mutationFn, success, error, onDone }) {
|
|||
*/
|
||||
export default function CandidateProfile({
|
||||
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
|
||||
variant = 'modal', browse = null, onBrowse,
|
||||
variant = 'modal',
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can, user } = useAuth()
|
||||
const isManager = isHiringManager(user)
|
||||
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchParams] = 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)
|
||||
|
|
@ -145,7 +130,7 @@ export default function CandidateProfile({
|
|||
// employer" changed every repaint. Fixed per candidate.
|
||||
const priorCompany = useMemo(() => pick(companies), [])
|
||||
|
||||
const candidateInterviews = (Array.isArray(interviews) ? interviews : []).filter((i) => i.candidateId === c.id)
|
||||
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
|
||||
|
||||
// The application row interviews/activity/feedback attach to. Detail mode
|
||||
// flattens every application the candidate owns; writes land on the first,
|
||||
|
|
@ -168,7 +153,7 @@ export default function CandidateProfile({
|
|||
const setFavorite = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
|
||||
success: (next) => (next ? `${live?.name || c.name || 'Candidate'} added to favorites` : 'Removed from favorites'),
|
||||
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
|
||||
})
|
||||
|
||||
// Same PATCH as favorite: the server writes rating onto every inbox row the
|
||||
|
|
@ -180,24 +165,14 @@ export default function CandidateProfile({
|
|||
success: (next) => `Rating saved — ${next}/5`,
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Re-score from stored professional_summary (candidates table), not a PDF re-read.
|
||||
const canRerunAts = isLive && Boolean(live) && (can('candidates.create') || can('candidates.edit'))
|
||||
const rerunAts = useProfileWrite({
|
||||
// Score the candidate's inbox CV against the assigned job with the ATS engine.
|
||||
// Needs both an assigned job (what to score against) and a message (whose
|
||||
// attachment to score); the refetch lands the new ai_score in this modal.
|
||||
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
|
||||
const scoreAts = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: () => candidatesApi.rerunAts({
|
||||
userId: c.userId,
|
||||
inboxMessageId: live?.message_id || undefined,
|
||||
manualUploadCandidateId: live?.manual_upload_candidate_id || undefined,
|
||||
}),
|
||||
success: 'ATS match updated from the stored summary',
|
||||
error: 'Could not run ATS match. Please try again.',
|
||||
onDone: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.analytics.all() })
|
||||
},
|
||||
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
|
||||
success: 'CV scored against the assigned job',
|
||||
})
|
||||
|
||||
const title = live?.job_title || c.currentTitle
|
||||
|
|
@ -214,6 +189,9 @@ export default function CandidateProfile({
|
|||
? KANBAN_ORDER[stageIdx + 1]
|
||||
: null
|
||||
|
||||
// The REAL stage move (PATCH /candidate/stage) — the seed-only onAdvance walk
|
||||
// is kept for seed candidates only. Requires pipeline.edit server-side.
|
||||
const qc = useQueryClient()
|
||||
const advanceLive = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: () => pipelineApi.changeStage({
|
||||
|
|
@ -230,23 +208,6 @@ 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
|
||||
|
|
@ -256,7 +217,7 @@ export default function CandidateProfile({
|
|||
|
||||
const counts = live && {
|
||||
Interview: live.interviews?.length ?? 0,
|
||||
Forms: (Array.isArray(formsQuery.data?.data) ? formsQuery.data.data : []).filter((r) => r.form_type !== 'requisition').length,
|
||||
Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length,
|
||||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.length ?? 0,
|
||||
Documents: live.documents?.length ?? 0,
|
||||
|
|
@ -271,7 +232,6 @@ 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>
|
||||
|
|
@ -279,31 +239,28 @@ export default function CandidateProfile({
|
|||
|
||||
const actions = isManager ? null : (
|
||||
<>
|
||||
<div className="flex gap-8" style={{ marginRight: 'auto', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
>
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
{canScoreAts && (
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
className="btn btn-secondary"
|
||||
disabled={scoreAts.isPending}
|
||||
onClick={() => scoreAts.mutate()}
|
||||
>
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
|
||||
</button>
|
||||
{isLive && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={!live || rerunAts.isPending || !canRerunAts}
|
||||
title={
|
||||
!can('candidates.create') && !can('candidates.edit')
|
||||
? 'Needs candidates.create'
|
||||
: !live?.assigned_job_post_id && !(live?.suggested_job_post_ids || []).length && !live?.manual_upload_candidate_id
|
||||
? 'Assign a job before running ATS'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => rerunAts.mutate()}
|
||||
>
|
||||
<Icon name="target" /> {rerunAts.isPending ? 'Matching…' : 'ATS Match'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onAtsMatch && (
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
</button>
|
||||
)}
|
||||
{isLive ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
|
|
@ -327,7 +284,7 @@ export default function CandidateProfile({
|
|||
|
||||
const body = (
|
||||
<>
|
||||
{variant === 'page' ? <CandidateWorkspaceHero candidate={live || c} stage={live ? stageLabel : null} restricted={isManager} /> : <div className="profile-hero">
|
||||
<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">
|
||||
|
|
@ -372,25 +329,19 @@ export default function CandidateProfile({
|
|||
{live?.professional_summary ? <div className="ats-summary">{live.professional_summary}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div className={variant === 'page' ? 'cw-tabs' : undefined} style={variant === 'page' ? undefined : { marginTop: 22 }}>
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<Tabs
|
||||
idBase={tabId}
|
||||
value={tab}
|
||||
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 }))}
|
||||
onChange={setTab}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (guard || (live ? (
|
||||
<>
|
||||
<PreviousApplications row={live} />
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
|
|
@ -445,11 +396,11 @@ export default function CandidateProfile({
|
|||
</>
|
||||
)}
|
||||
|
||||
{Array.isArray(live.job_posts) && live.job_posts.length > 0 && (
|
||||
{live.job_posts?.length > 0 && (
|
||||
<>
|
||||
<div style={LABEL}>Suggested Roles</div>
|
||||
<div className="k-tags">
|
||||
{live.job_posts.filter(Boolean).map((j) => <span className="tag" key={j.id || j.title}>{j.title}</span>)}
|
||||
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -471,7 +422,7 @@ export default function CandidateProfile({
|
|||
<Info label="Rating" val={`⭐ ${c.rating} / 5.0`} />
|
||||
</div>
|
||||
<div style={LABEL}>Skills</div>
|
||||
<div className="k-tags">{(Array.isArray(c.skills) ? c.skills : []).map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</>
|
||||
)))}
|
||||
|
||||
|
|
@ -662,34 +613,17 @@ export default function CandidateProfile({
|
|||
return (
|
||||
<div className="cand-page">
|
||||
<div className="cand-page-bar">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>
|
||||
<Icon name="chevron-left" /> Back
|
||||
</button>
|
||||
<div className="cand-page-crumb">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>
|
||||
<Icon name="chevron-left" /> Back
|
||||
</button>
|
||||
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
<div className="cand-page-actions">
|
||||
{!isManager && <button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>}
|
||||
{onBrowse && <CandidateBrowseNav browse={browse} onBrowse={onBrowse} />}
|
||||
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
|
||||
</div>
|
||||
{actions && <div className="cand-page-actions">{actions}</div>}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-body">{body}</div>
|
||||
</div>
|
||||
{body}
|
||||
{dialog && live && <Modal
|
||||
title={{ interview: 'Schedule Interview', note: 'Add Note', stage: dialog.stage === 'Rejected' ? 'Reject Application' : 'Move to Stage', share: 'Share Profile' }[dialog.type]}
|
||||
subtitle={live.name}
|
||||
size="candidate-dialog"
|
||||
onClose={() => { if (!moveStage.isPending) setDialog(null) }}
|
||||
>
|
||||
{dialog.type === 'interview' && <InterviewTab userId={c.userId} inboxId={inboxId} rows={[]} onSaved={() => setDialog(null)} />}
|
||||
{dialog.type === 'note' && <NotesTab userId={c.userId} rows={[]} onSaved={() => setDialog(null)} />}
|
||||
{dialog.type === 'share' && <div className="form-field"><label htmlFor="candidate-share-link">Profile link</label><input id="candidate-share-link" readOnly value={window.location.href} onFocus={(event) => event.target.select()} /><p className="text-muted">Copy this link to share with a member of your hiring team.</p></div>}
|
||||
{dialog.type === 'stage' && <form onSubmit={(event) => { event.preventDefault(); if (!moveStage.isPending) moveStage.mutate() }}>
|
||||
<p className="cw-summary">{live.job_title || 'Current application'} · Currently {stageLabel}</p>
|
||||
<div className="form-field"><label htmlFor="candidate-stage">Move to</label><select id="candidate-stage" value={dialog.stage} disabled={moveStage.isPending} onChange={(event) => setDialog({ ...dialog, stage: event.target.value })}>{Object.keys(pipelineApi.STATUS_FROM_STAGE).map((stage) => <option key={stage}>{stage}</option>)}</select></div>
|
||||
<div className="form-field"><label htmlFor="candidate-stage-reason">Reason{['Rejected', 'On Hold'].includes(dialog.stage) ? ' (required)' : ' (optional)'}</label><textarea id="candidate-stage-reason" value={stageReason} disabled={moveStage.isPending} onChange={(event) => setStageReason(event.target.value)} required={['Rejected', 'On Hold'].includes(dialog.stage)} placeholder="Add context for the hiring team…" /></div>
|
||||
<div className="cw-dialog-actions"><button type="button" className="btn btn-secondary" disabled={moveStage.isPending} onClick={() => setDialog(null)}>Cancel</button><button type="submit" className={`btn ${dialog.stage === 'Rejected' ? 'cw-danger' : 'btn-primary'}`} disabled={!can('pipeline.edit') || moveStage.isPending || dialog.stage === stageLabel || (['Rejected', 'On Hold'].includes(dialog.stage) && !stageReason.trim())}>{moveStage.isPending ? 'Saving…' : dialog.stage === 'Rejected' ? 'Reject Application' : 'Save Stage'}</button></div>
|
||||
</form>}
|
||||
</Modal>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -962,9 +896,8 @@ function HistoryRow({ row: r }) {
|
|||
)
|
||||
}
|
||||
|
||||
function InterviewTab({ userId, inboxId, rows, onSaved }) {
|
||||
function InterviewTab({ userId, inboxId, rows }) {
|
||||
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 }))
|
||||
|
||||
|
|
@ -976,7 +909,7 @@ function InterviewTab({ userId, inboxId, rows, onSaved }) {
|
|||
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] }); onSaved?.() },
|
||||
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
|
|
@ -1043,7 +976,7 @@ function InterviewTab({ userId, inboxId, rows, onSaved }) {
|
|||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending || !(can('interviews.create') || can('candidates.create'))}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
|
||||
|
|
@ -1058,14 +991,13 @@ function InterviewTab({ userId, inboxId, rows, onSaved }) {
|
|||
)
|
||||
}
|
||||
|
||||
function NotesTab({ userId, rows, onSaved }) {
|
||||
const { can } = useAuth()
|
||||
function NotesTab({ userId, rows }) {
|
||||
const [text, setText] = useState('')
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
|
||||
success: 'Note saved',
|
||||
onDone: () => { setText(''); onSaved?.() },
|
||||
onDone: () => setText(''),
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -1081,7 +1013,7 @@ function NotesTab({ userId, rows, onSaved }) {
|
|||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ margin: '10px 0 18px' }}
|
||||
disabled={!text.trim() || create.isPending || !can('candidates.create')}
|
||||
disabled={!text.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/* ============================================================
|
||||
Candidates — applications on live backend data.
|
||||
|
||||
Recruiter rows come from GET /candidate/fetch (inbox + manual + form), one row
|
||||
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
||||
per application, so score / stage / job / recruiter have a source.
|
||||
Without candidates.manage the server scopes that list to jobs the user
|
||||
owns as recruiter (or created). Tick Requisitions → Configure in Access
|
||||
|
|
@ -35,11 +35,10 @@ 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 EMPTY_FILTERS = { account: '', stage: '', band: '' }
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
const STAGE_FILTERS = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
|
||||
const STAGE_FILTERS = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
|
||||
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
|
||||
const BAND_BADGE = {
|
||||
'Strong Match': 'b-green',
|
||||
|
|
@ -56,13 +55,12 @@ const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer
|
|||
stage, job and recruiter all hang off the application — on a users row those
|
||||
columns have no source at all. One row per application is what a recruiter
|
||||
triages on, so the table follows the application. */
|
||||
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId, assignment } = {}) {
|
||||
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId } = {}) {
|
||||
const res = await candidatesApi.list({
|
||||
limit,
|
||||
offset,
|
||||
search: search || undefined,
|
||||
assignedJobPostId: assignedJobPostId || undefined,
|
||||
assignment: assignment || undefined,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return {
|
||||
|
|
@ -138,7 +136,6 @@ const REFERRAL_RE = new RegExp(
|
|||
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
||||
|
||||
const STAGE_BADGE = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
@ -162,6 +159,11 @@ 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 () => {
|
||||
|
|
@ -192,11 +194,6 @@ 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',
|
||||
|
|
@ -289,7 +286,7 @@ function HiringManagerCandidates() {
|
|||
? 'No candidates match these filters.'
|
||||
: 'No candidates are allocated to jobs opened from your requisitions yet.'
|
||||
}
|
||||
onRowClick={(r) => r.user_id && openCandidateProfile(navigate, r.user_id, rows)}
|
||||
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -326,26 +323,18 @@ function RecruiterCandidates() {
|
|||
}, [q])
|
||||
useEffect(() => { setSkip(0) }, [search])
|
||||
|
||||
const assignmentParam = filters.assignment === 'Assigned'
|
||||
? 'assigned'
|
||||
: filters.assignment === 'Unassigned'
|
||||
? 'unassigned'
|
||||
: undefined
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: qk.candidates.list({
|
||||
limit: pageSize,
|
||||
offset: skip,
|
||||
search,
|
||||
assignedJobPostId: jobId || undefined,
|
||||
assignment: assignmentParam,
|
||||
}),
|
||||
queryFn: () => fetchCandidates({
|
||||
limit: pageSize,
|
||||
offset: skip,
|
||||
search,
|
||||
assignedJobPostId: jobId || undefined,
|
||||
assignment: assignmentParam,
|
||||
}),
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
|
|
@ -393,10 +382,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) openCandidateProfile(navigate, uid, candidates)
|
||||
if (uid) navigate(`/candidate/${uid}`)
|
||||
else setProfileFor(c)
|
||||
},
|
||||
[qc, navigate, candidates],
|
||||
[qc, navigate],
|
||||
)
|
||||
|
||||
// Deep links from Talent Pool, global search, dashboard…
|
||||
|
|
@ -404,12 +393,14 @@ function RecruiterCandidates() {
|
|||
const st = location.state
|
||||
if (!st) return
|
||||
if (st.openAdd) setAdding(true)
|
||||
if (st.openCandidate) openCandidateProfile(navigate, st.openCandidate, candidates.length ? candidates : undefined, { replace: true })
|
||||
}, [location.state, navigate, candidates])
|
||||
if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
|
||||
}, [location.state, navigate])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
let list = candidates.filter((c) => {
|
||||
if (f.account === 'Active' && !c.isActive) return false
|
||||
if (f.account === 'Unconfirmed' && c.isActive) return false
|
||||
if (f.stage && (c.stage || '') !== f.stage) return false
|
||||
if (f.band === 'Unscored' && c.aiScore != null) return false
|
||||
if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false
|
||||
|
|
@ -617,7 +608,7 @@ function RecruiterCandidates() {
|
|||
GET /candidate/fetch. */}
|
||||
<Facet label="Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} />
|
||||
<Facet label="ATS band" value={filters.band} onChange={(v) => setFilter('band', v)} any="Any band" options={BAND_FILTERS} />
|
||||
<Facet label="Assignment" value={filters.assignment} onChange={(v) => setFilter('assignment', v)} any="Any assignment" options={['Assigned', 'Unassigned']} />
|
||||
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -741,6 +732,7 @@ function RecruiterCandidates() {
|
|||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,19 @@
|
|||
job. Read live from their application rather than copied
|
||||
here, so there is one source of truth and nothing to sync.
|
||||
|
||||
ATS is a real paid score. Each row picks a job, then Run ATS scores that
|
||||
one candidate. Speculative rows are assigned to the job first (so the banked
|
||||
CV links like any other application), then scored. Silver medalists stay on
|
||||
their inbox application and score via score_inbox when a message id exists.
|
||||
Two very different numbers live on this screen and must not be confused:
|
||||
|
||||
Match free, deterministic keyword overlap against the job picked in
|
||||
"Rank against job". It orders the pile. It is not an assessment.
|
||||
ATS a real paid score, and only present once someone ran one. The
|
||||
"Score against job" action is what runs it, deliberately per-row.
|
||||
|
||||
That split is the whole design: ranking the bank costs nothing and happens
|
||||
automatically when a job opens, so scoring can stay explicit and cheap.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
|
|
@ -25,14 +30,12 @@ import OpenResumeButton from '../ui/OpenResumeButton'
|
|||
import PageHeader from '../ui/PageHeader'
|
||||
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||
import { PickRoleModal } from '../ui/SuggestedRoles'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
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
|
||||
|
|
@ -52,11 +55,10 @@ const SOURCE_BADGE = {
|
|||
/* Chips past this are collapsed into "+N" — a CV with 25 skills would
|
||||
otherwise make one row taller than the rest of the page. */
|
||||
const SKILL_CHIPS = 4
|
||||
const SUGGESTED_CHIPS = 3
|
||||
|
||||
const EMPTY_FILTERS = { source: '', band: '', years: '' }
|
||||
|
||||
async function fetchBank({ limit, offset, search, filters }) {
|
||||
async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
||||
const res = await candidatesApi.listCvBank({
|
||||
top: limit,
|
||||
skip: offset,
|
||||
|
|
@ -64,6 +66,7 @@ async function fetchBank({ limit, offset, search, filters }) {
|
|||
source: filters.source || undefined,
|
||||
band: filters.band || undefined,
|
||||
minYears: filters.years ? Number(filters.years) : undefined,
|
||||
jobPostId: jobPostId || undefined,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return {
|
||||
|
|
@ -72,6 +75,30 @@ async function fetchBank({ limit, offset, search, filters }) {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
const res = await candidatesApi.listJobs()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((row) => ({ id: String(row.id), title: row.title }))
|
||||
}
|
||||
|
||||
/** The free deterministic rank. Drawn as a plain bar, never as a ScoreChip —
|
||||
a recruiter must not read it in the same visual language as a real ATS score. */
|
||||
function MatchCell({ rank, hasJob }) {
|
||||
if (!hasJob) return <span className="text-muted text-sm">Pick a job</span>
|
||||
if (rank == null) return <span className="text-muted">—</span>
|
||||
return (
|
||||
<div style={{ minWidth: 92 }}>
|
||||
<div className="fw-600 text-sm">{rank}<span className="text-muted" style={{ fontWeight: 400 }}>/100</span></div>
|
||||
<div
|
||||
style={{ height: 4, borderRadius: 2, background: 'var(--bg-sunken)', marginTop: 4 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div style={{ width: `${Math.min(100, rank)}%`, height: '100%', borderRadius: 2, background: 'var(--primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AtsCell({ score, recommendation }) {
|
||||
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
||||
return (
|
||||
|
|
@ -86,43 +113,11 @@ function AtsCell({ score, recommendation }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SuggestedJobsCell({ jobs }) {
|
||||
if (!jobs?.length) return null
|
||||
return (
|
||||
<div className="k-tags">
|
||||
{jobs.slice(0, SUGGESTED_CHIPS).map((j) => (
|
||||
<span className="tag" key={j.id} title={j.title}>{j.title || 'Job'}</span>
|
||||
))}
|
||||
{jobs.length > SUGGESTED_CHIPS && (
|
||||
<span className="tag" title={jobs.slice(SUGGESTED_CHIPS).map((j) => j.title).join(', ')}>
|
||||
+{jobs.length - SUGGESTED_CHIPS}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function jobFor(row, pickedById) {
|
||||
const local = pickedById[row.id]
|
||||
if (local?.id) return local
|
||||
if (row.scoredJobPostId) {
|
||||
return { id: row.scoredJobPostId, title: row.scoredJobTitle || 'Selected job' }
|
||||
}
|
||||
if (row.assignedJobPostId) {
|
||||
return { id: row.assignedJobPostId, title: row.assignedJobTitle || 'Assigned job' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function scoredRow(res) {
|
||||
const row = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||
return row && typeof row === 'object' ? row : null
|
||||
}
|
||||
|
||||
export default function CvBank() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -131,8 +126,19 @@ export default function CvBank() {
|
|||
const [skip, setSkip] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
||||
const [pickingRow, setPickingRow] = useState(null)
|
||||
const [pickedById, setPickedById] = useState({})
|
||||
const [scoreFor, setScoreFor] = useState(null)
|
||||
const [assignFor, setAssignFor] = useState(null)
|
||||
|
||||
/* The rank job lives in the URL so the notification fired on job creation
|
||||
("/cvbank?job=<id>") lands on the ranked view rather than a generic list. */
|
||||
const jobPostId = params.get('job') || ''
|
||||
const setJobPostId = (next) => {
|
||||
const p = new URLSearchParams(params)
|
||||
if (next) p.set('job', next)
|
||||
else p.delete('job')
|
||||
setParams(p, { replace: true })
|
||||
setSkip(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||
|
|
@ -141,12 +147,15 @@ export default function CvBank() {
|
|||
useEffect(() => { setSkip(0) }, [search])
|
||||
|
||||
const bankQuery = useQuery({
|
||||
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters }),
|
||||
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters }),
|
||||
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters, jobPostId }),
|
||||
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters, jobPostId }),
|
||||
})
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||
|
||||
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
||||
const total = bankQuery.data?.total ?? 0
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const selectedJob = jobs.find((j) => j.id === jobPostId) || null
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const from = total ? skip + 1 : 0
|
||||
|
|
@ -164,9 +173,8 @@ export default function CvBank() {
|
|||
{ key: 'title', label: 'Role', sortable: true },
|
||||
{ key: 'years', label: 'Years', sortable: true },
|
||||
{ key: 'skills', label: 'Skills', sortable: false },
|
||||
{ key: 'suggestedJobs', label: 'Suggested jobs', sortable: false },
|
||||
{ key: 'rankScore', label: 'Match', sortable: true },
|
||||
{ key: 'aiScore', label: 'ATS', sortable: true },
|
||||
{ key: 'job', label: 'Job', sortable: false },
|
||||
{ key: 'added', label: 'Added', sortable: true },
|
||||
{ key: 'actions', label: '', sortable: false },
|
||||
], [])
|
||||
|
|
@ -188,35 +196,36 @@ export default function CvBank() {
|
|||
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
||||
})
|
||||
|
||||
const runAts = useMutation({
|
||||
mutationFn: async ({ row, jobId }) => {
|
||||
if (row.isStoredCv) {
|
||||
await candidatesApi.assignMatchingJob(row.recordId, jobId)
|
||||
return candidatesApi.scoreCvBank(jobId, [row.recordId])
|
||||
}
|
||||
if (row.messageId) {
|
||||
return candidatesApi.scoreInbox(jobId, [row.messageId])
|
||||
}
|
||||
throw new Error('This applicant cannot be scored from the CV Bank')
|
||||
},
|
||||
onSuccess: (res, vars) => {
|
||||
const row = scoredRow(res)
|
||||
const title = vars.jobTitle || 'the selected job'
|
||||
if (row?.status === 'completed' || (row?.match_score != null && row?.status !== 'failed')) {
|
||||
toast(`Scored ${row.match_score}/100 against ${title}`, 'success')
|
||||
const scoring = useMutation({
|
||||
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids),
|
||||
onSuccess: (res) => {
|
||||
const row = Array.isArray(res?.data) ? res.data[0] : null
|
||||
if (row?.status === 'completed') {
|
||||
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success')
|
||||
} else {
|
||||
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
setScoreFor(null)
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
|
||||
})
|
||||
|
||||
const assigning = useMutation({
|
||||
mutationFn: ({ id, jobId }) => candidatesApi.assignMatchingJob(id, jobId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
toast('CV assigned — it is in the pipeline now', 'success')
|
||||
setAssignFor(null)
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the job'), 'error'),
|
||||
})
|
||||
|
||||
async function view(row) {
|
||||
if (!row.isStoredCv) {
|
||||
if (row.userId) openCandidateProfile(navigate, row.userId, rows)
|
||||
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||||
else toast('This applicant has no profile to open', 'info')
|
||||
return
|
||||
}
|
||||
|
|
@ -260,7 +269,7 @@ export default function CvBank() {
|
|||
await exportStyledXlsx({
|
||||
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
||||
title: 'CV Bank',
|
||||
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
|
||||
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'}${selectedJob ? ` · ranked against ${selectedJob.title}` : ''} · exported ${new Date().toLocaleDateString()}`,
|
||||
columns: [
|
||||
{ header: 'Name', key: 'name', width: 26 },
|
||||
{ header: 'Email', key: 'email', width: 30 },
|
||||
|
|
@ -269,11 +278,12 @@ export default function CvBank() {
|
|||
{ header: 'Company', key: 'company', width: 24 },
|
||||
{ header: 'Years', key: 'years', width: 8 },
|
||||
{ header: 'Skills', key: 'skills', width: 42 },
|
||||
{ header: 'Suggested jobs', key: 'suggested', width: 32 },
|
||||
{ header: 'Match', key: 'match', width: 10 },
|
||||
{ header: 'ATS', key: 'ats', width: 10 },
|
||||
{ header: 'Scored job', key: 'scoredJob', width: 24 },
|
||||
{ header: 'Added', key: 'added', width: 12 },
|
||||
],
|
||||
// Every column is extracted from the CV or read off a real application.
|
||||
// Talent Pool exported invented skills and companies; this does not.
|
||||
rows: rows.map((r) => ({
|
||||
name: r.name,
|
||||
email: r.email || '',
|
||||
|
|
@ -282,9 +292,8 @@ export default function CvBank() {
|
|||
company: r.company || '',
|
||||
years: r.years ?? '',
|
||||
skills: r.skills.join(', '),
|
||||
suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
|
||||
match: r.rankScore ?? '',
|
||||
ats: r.aiScore ?? '',
|
||||
scoredJob: jobFor(r, pickedById)?.title || '',
|
||||
added: r.added ? r.added.toLocaleDateString() : '',
|
||||
})),
|
||||
})
|
||||
|
|
@ -300,7 +309,7 @@ export default function CvBank() {
|
|||
title="CV Bank"
|
||||
sub={
|
||||
bankQuery.isSuccess
|
||||
? <>{total} CV{total === 1 ? '' : 's'} held for future roles</>
|
||||
? <>{total} CV{total === 1 ? '' : 's'} held for future roles{selectedJob ? <> · ranked against <strong>{selectedJob.title}</strong></> : null}</>
|
||||
: 'CVs held for future roles'
|
||||
}
|
||||
actions={<>
|
||||
|
|
@ -328,6 +337,20 @@ export default function CvBank() {
|
|||
<Icon name="filter" /> Filters
|
||||
</button>
|
||||
<div className="spacer" />
|
||||
{/* The "a role just opened, who do we already have" control. This is
|
||||
the moment the bank is meant to be used. */}
|
||||
<div className="flex items-center gap-8">
|
||||
<label className="text-muted text-sm">Rank against job:</label>
|
||||
<select
|
||||
className="select"
|
||||
value={jobPostId}
|
||||
onChange={(e) => setJobPostId(e.target.value)}
|
||||
disabled={jobsQuery.isPending}
|
||||
>
|
||||
<option value="">No job selected</option>
|
||||
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
|
|
@ -395,124 +418,100 @@ export default function CvBank() {
|
|||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
t.pageRows.map((r) => {
|
||||
const picked = jobFor(r, pickedById)
|
||||
const scoringThis = runAts.isPending && runAts.variables?.row?.id === r.id
|
||||
const runDisabled = !r.canRunAts || !picked || runAts.isPending
|
||||
const runTitle = !r.canRunAts
|
||||
? 'Silver medalists are scored from their application — this row has no inbox CV to score'
|
||||
: !picked
|
||||
? 'Pick a job first'
|
||||
: 'Run the ATS score for this candidate'
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="cell-primary">{r.name}</div>
|
||||
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||||
</div>
|
||||
t.pageRows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="cell-primary">{r.name}</div>
|
||||
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||||
{r.lastJobTitle && (
|
||||
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||||
applied for {r.lastJobTitle}
|
||||
</div>
|
||||
)}
|
||||
{r.expiresAt && (
|
||||
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||||
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.skills.length ? (
|
||||
<div className="k-tags">
|
||||
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||||
<span className="tag" key={s}>{s}</span>
|
||||
))}
|
||||
{r.skills.length > SKILL_CHIPS && (
|
||||
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||||
+{r.skills.length - SKILL_CHIPS}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted text-sm">None extracted</span>
|
||||
)}
|
||||
</td>
|
||||
<td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
|
||||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setPickingRow(r)}
|
||||
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
|
||||
>
|
||||
{picked?.title || 'Pick a job'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||||
{r.lastJobTitle && (
|
||||
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||||
applied for {r.lastJobTitle}
|
||||
</div>
|
||||
)}
|
||||
{r.expiresAt && (
|
||||
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||||
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.skills.length ? (
|
||||
<div className="k-tags">
|
||||
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||||
<span className="tag" key={s}>{s}</span>
|
||||
))}
|
||||
{r.skills.length > SKILL_CHIPS && (
|
||||
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||||
+{r.skills.length - SKILL_CHIPS}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted text-sm">None extracted</span>
|
||||
)}
|
||||
</td>
|
||||
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td>
|
||||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||
<td>
|
||||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||||
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
{r.isStoredCv && (<>
|
||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={runDisabled}
|
||||
title={runTitle}
|
||||
className="act-btn"
|
||||
data-tip="Score against a job"
|
||||
aria-label="Score this CV against a job"
|
||||
onClick={() => setScoreFor(r)}
|
||||
>
|
||||
<Icon name="target" />
|
||||
</button>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Assign to a job"
|
||||
aria-label="Assign this CV to a job"
|
||||
onClick={() => setAssignFor(r)}
|
||||
>
|
||||
<Icon name="briefcase" />
|
||||
</button>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Remove"
|
||||
aria-label="Remove CV from bank"
|
||||
disabled={removing.isPending}
|
||||
onClick={() => {
|
||||
if (!picked || runDisabled) return
|
||||
runAts.mutate({
|
||||
row: r,
|
||||
jobId: picked.id,
|
||||
jobTitle: picked.title,
|
||||
})
|
||||
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||||
removing.mutate(r.recordId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{scoringThis ? 'Scoring…' : 'Run ATS'}
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||||
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
{r.isStoredCv && (<>
|
||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
{!r.assignedJobPostId && (
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Remove"
|
||||
aria-label="Remove CV from bank"
|
||||
disabled={removing.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||||
removing.mutate(r.recordId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
</>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -534,23 +533,36 @@ export default function CvBank() {
|
|||
</div>
|
||||
|
||||
<p className="text-muted text-sm mt-18">
|
||||
<Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
|
||||
real scored result. Speculative CVs are linked to that job; silver medalists
|
||||
stay on their existing application.
|
||||
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected
|
||||
job — it orders this list, it does not assess anyone. <strong>ATS</strong> is a real
|
||||
scored result and only appears once someone runs one.
|
||||
</p>
|
||||
|
||||
{pickingRow && (
|
||||
<PickRoleModal
|
||||
title="Pick a job"
|
||||
subtitle="Search open job posts to score this CV against"
|
||||
onClose={() => setPickingRow(null)}
|
||||
onPick={(post) => {
|
||||
if (!post?.id) return
|
||||
setPickedById((m) => ({
|
||||
...m,
|
||||
[pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
|
||||
}))
|
||||
}}
|
||||
{scoreFor && (
|
||||
<JobPickerModal
|
||||
title="Score against a job"
|
||||
subtitle={`Run the real ATS score for ${scoreFor.name}`}
|
||||
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV."
|
||||
confirmLabel="Score CV"
|
||||
jobs={jobs}
|
||||
defaultJobId={jobPostId}
|
||||
pending={scoring.isPending}
|
||||
onClose={() => setScoreFor(null)}
|
||||
onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{assignFor && (
|
||||
<JobPickerModal
|
||||
title="Assign to a job"
|
||||
subtitle={`Move ${assignFor.name} onto a job post`}
|
||||
note="The CV leaves the bank and enters the pipeline as an application. It is not scored by this action."
|
||||
confirmLabel="Assign"
|
||||
jobs={jobs}
|
||||
defaultJobId={jobPostId}
|
||||
pending={assigning.isPending}
|
||||
onClose={() => setAssignFor(null)}
|
||||
onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -586,3 +598,42 @@ function Facet({ label, value, onChange, any, options, labels }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared by the score and assign actions — both need exactly one job post. */
|
||||
function JobPickerModal({ title, subtitle, note, confirmLabel, jobs, defaultJobId, pending, onClose, onConfirm }) {
|
||||
const [jobId, setJobId] = useState(defaultJobId || (jobs[0]?.id ?? ''))
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
footer={<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={pending || !jobId}
|
||||
onClick={() => onConfirm(jobId)}
|
||||
>
|
||||
<Icon name="check" /> {pending ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
{jobs.length === 0 ? (
|
||||
<EmptyState icon="briefcase" title="No job posts yet">
|
||||
Create a job post first — there is nothing to match against.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Job post</label>
|
||||
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-muted text-sm">{note}</p>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/
|
|||
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate, toInstant } from '../lib/format'
|
||||
|
|
@ -31,29 +32,14 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as inboxApi from '../api/inbox'
|
||||
import * as sheetApi from '../api/sheet'
|
||||
import * as s3Api from '../api/s3'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, initials as initialsOf,
|
||||
inboxSources, sourceMeta,
|
||||
} from '../data/seed'
|
||||
|
||||
const TABS = ['All Applications', 'Suggested Match', 'Unread', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
|
||||
const TABS = ['All Applications', 'Unread', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
|
||||
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
|
||||
const FORM_TABS = ['All Applications', 'Suggested Match', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
|
||||
const TAB_HINTS = {
|
||||
'All Applications': 'Every application in this channel',
|
||||
'Suggested Match': 'Matcher found at least one job',
|
||||
Unread: 'Email not marked read yet',
|
||||
Processed: 'Recruiter imported or shortlisted',
|
||||
'On-Hold': 'No suggested job found',
|
||||
Rejected: 'Recruiter rejected from the inbox',
|
||||
Duplicates: 'Flagged duplicates and reapplicants',
|
||||
}
|
||||
const CHANNEL_HINTS = {
|
||||
all: 'Email and forms together',
|
||||
email: 'Outlook applications with a resume',
|
||||
forms: 'Google Form applicants',
|
||||
}
|
||||
const FORM_TABS = ['All Applications', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
|
||||
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
|
||||
const PAGE_SIZE_MAX = 500
|
||||
|
||||
|
|
@ -192,7 +178,6 @@ const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet'
|
|||
* processing_state / no_suggestions columns on form_data.
|
||||
*/
|
||||
const TAB_FILTERS = {
|
||||
'Suggested Match': { hasSuggestions: true },
|
||||
Unread: { isread: false },
|
||||
Processed: { processingState: 'processed' },
|
||||
'On-Hold': { noSuggestions: true },
|
||||
|
|
@ -201,7 +186,6 @@ const TAB_FILTERS = {
|
|||
}
|
||||
|
||||
const FORM_TAB_FILTERS = {
|
||||
'Suggested Match': { has_suggestions: true },
|
||||
Processed: { processing_state: 'processed' },
|
||||
'On-Hold': { no_suggestions: true },
|
||||
Rejected: { processing_state: 'rejected' },
|
||||
|
|
@ -452,7 +436,6 @@ function mapFormRow(row) {
|
|||
noticePeriod: row.notice_period || '',
|
||||
currentSalary: row.current_salary || '',
|
||||
expectedSalary: row.expected_salary || '',
|
||||
experience: (row.experience || row.experience_details || '').trim(),
|
||||
profileLink: row.profile_link || '',
|
||||
resumeLink: row.resume_link || '',
|
||||
sheet: row.sheet || '',
|
||||
|
|
@ -529,22 +512,6 @@ function htmlToText(value) {
|
|||
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
const AGENT_SENTINELS = new Set([
|
||||
'no company was mentioned',
|
||||
'no education mentioned',
|
||||
'no education mentioned.',
|
||||
'no job position mentioned',
|
||||
'no city mentioned',
|
||||
'no name mentioned',
|
||||
])
|
||||
|
||||
function extractedText(value) {
|
||||
if (value == null) return ''
|
||||
const text = String(value).trim()
|
||||
if (!text) return ''
|
||||
return AGENT_SENTINELS.has(text.toLowerCase()) ? '' : text
|
||||
}
|
||||
|
||||
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
|
||||
const RESUME_STATUS = {
|
||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||
|
|
@ -599,15 +566,6 @@ async function fetchMessageDetail(recordId) {
|
|||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.fromEmail || '',
|
||||
phone: row.phone || '',
|
||||
experience: row.experience || '',
|
||||
currentTitle: extractedText(row.current_title),
|
||||
currentCompany: extractedText(row.current_employment),
|
||||
city: row.city || '',
|
||||
residingCity: row.city || '',
|
||||
education: extractedText(row.education),
|
||||
recruiter: row.recruiter || '',
|
||||
recruiterId: row.recruiter_id || null,
|
||||
position: row.subject || '(no subject)',
|
||||
...sourceFrom(row.message_to),
|
||||
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
|
||||
|
|
@ -622,6 +580,7 @@ async function fetchMessageDetail(recordId) {
|
|||
bodyHtml: row.body || '',
|
||||
cc: row.message_cc || '',
|
||||
bcc: row.message_bcc || '',
|
||||
sentAt: parseGraphDate(row.message_sent_time),
|
||||
files: Array.isArray(row.files) ? row.files : [],
|
||||
filePath: row.file_path || '',
|
||||
linkedinSlug: row.linkedin_slug || '',
|
||||
|
|
@ -687,11 +646,7 @@ async function fetchApplications(params) {
|
|||
atsScore: emailAtsScore(row),
|
||||
phone: row.phone,
|
||||
experience: row.experience,
|
||||
currentTitle: extractedText(row.current_title),
|
||||
currentCompany: extractedText(row.current_employment),
|
||||
education: extractedText(row.education),
|
||||
recruiter: row.recruiter,
|
||||
recruiterId: row.recruiter_id || null,
|
||||
city: row.city || '',
|
||||
residingCity: row.city || '',
|
||||
duplicate: Boolean(row.duplicate),
|
||||
|
|
@ -1231,6 +1186,8 @@ export default function Inbox() {
|
|||
const qc = useQueryClient()
|
||||
const { can } = useAuth()
|
||||
const canEdit = can('inbox.edit')
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateInbox = useSeedMutation('inbox')
|
||||
|
||||
const [channel, setChannel] = useState('all')
|
||||
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
|
||||
|
|
@ -1489,7 +1446,6 @@ export default function Inbox() {
|
|||
: n((isForms ? f : e)[key]))
|
||||
return {
|
||||
'All Applications': pick('all'),
|
||||
'Suggested Match': pick('suggested'),
|
||||
Unread: pick('unread'),
|
||||
Processed: pick('processed'),
|
||||
'On-Hold': pick('on_hold'),
|
||||
|
|
@ -1568,10 +1524,6 @@ export default function Inbox() {
|
|||
...(detailQuery.data ?? {}),
|
||||
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
|
||||
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
|
||||
phone: detailQuery.data?.phone || selectedRow?.phone || '',
|
||||
experience: detailQuery.data?.experience || selectedRow?.experience || '',
|
||||
recruiter: detailQuery.data?.recruiter || selectedRow?.recruiter || '',
|
||||
recruiterId: detailQuery.data?.recruiterId || selectedRow?.recruiterId || null,
|
||||
}
|
||||
: null
|
||||
|
||||
|
|
@ -1920,7 +1872,6 @@ export default function Inbox() {
|
|||
role="tab"
|
||||
aria-selected={channel === c.key}
|
||||
className={`pill-tab${channel === c.key ? ' active' : ''}`}
|
||||
data-tip={CHANNEL_HINTS[c.key]}
|
||||
onClick={() => switchChannel(c.key)}
|
||||
>
|
||||
<Icon name={c.icon} /> {c.label}
|
||||
|
|
@ -1955,7 +1906,7 @@ export default function Inbox() {
|
|||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
tabs={channelTabs.map((t) => ({ key: t, label: t, count: counts[t], title: TAB_HINTS[t] }))}
|
||||
tabs={channelTabs.map((t) => ({ key: t, label: t, count: counts[t] }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -2148,9 +2099,6 @@ export default function Inbox() {
|
|||
{(i.city || i.residingCity) && (
|
||||
<span className="cell-sub">{i.city || i.residingCity}</span>
|
||||
)}
|
||||
{orDash(i.phone) !== '—' && (
|
||||
<span className="cell-sub">{i.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2234,7 +2182,6 @@ export default function Inbox() {
|
|||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
onAssignRecruiter={setAssigning}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2246,8 +2193,13 @@ export default function Inbox() {
|
|||
{assigning && (
|
||||
<AssignRecruiter
|
||||
item={assigning}
|
||||
toast={toast}
|
||||
recruiters={recruiters}
|
||||
onClose={() => setAssigning(null)}
|
||||
onSave={(name) => {
|
||||
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
|
||||
setAssigning(null)
|
||||
toast(`Recruiter assigned to ${assigning.name}`, 'success')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -2276,14 +2228,8 @@ export default function Inbox() {
|
|||
}
|
||||
|
||||
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
|
||||
const PHONE_PLACEHOLDER = /^xxx-xxx-xxxx$/i
|
||||
|
||||
function orDash(value, suffix = '') {
|
||||
if (value == null || value === '') return '—'
|
||||
const text = String(value).trim()
|
||||
if (!text || PHONE_PLACEHOLDER.test(text)) return '—'
|
||||
if (suffix && text.toLowerCase().includes(suffix.trim().toLowerCase())) return text
|
||||
return `${text}${suffix}`
|
||||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||
}
|
||||
|
||||
function externalHref(url) {
|
||||
|
|
@ -2519,7 +2465,6 @@ function FormApplicantDetail({
|
|||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
|
||||
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
|
||||
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
|
||||
|
|
@ -2679,7 +2624,7 @@ function FormApplicantDetail({
|
|||
}
|
||||
|
||||
function ApplicationDetail({
|
||||
item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate, onAssignRecruiter,
|
||||
item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate,
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||
|
|
@ -2790,10 +2735,7 @@ function ApplicationDetail({
|
|||
{i.name}
|
||||
<ReappliedBadge row={i} />
|
||||
</div>
|
||||
<div className="ph-role">
|
||||
{[extractedText(i.currentTitle), extractedText(i.currentCompany)].filter(Boolean).join(' at ')
|
||||
|| i.position}
|
||||
</div>
|
||||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||||
|
|
@ -2821,41 +2763,37 @@ function ApplicationDetail({
|
|||
|
||||
<PreviousApplications row={i} />
|
||||
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{(resumeKey || i.hasAttachment) && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={openResume.isPending || !canOpenResume}
|
||||
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
|
||||
>
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</button>
|
||||
)}
|
||||
{profileHref && (
|
||||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => onAssignRecruiter?.(i)}>
|
||||
<Icon name="user" /> {i.recruiter ? 'Change recruiter' : 'Assign recruiter'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(resumeKey || i.hasAttachment || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{(resumeKey || i.hasAttachment) && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={openResume.isPending || !canOpenResume}
|
||||
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
|
||||
>
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</button>
|
||||
)}
|
||||
{profileHref && (
|
||||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
|
||||
<div className="info-item"><div className="il">Current title</div><div className="iv">{orDash(extractedText(i.currentTitle))}</div></div>
|
||||
<div className="info-item"><div className="il">Current company</div><div className="iv">{orDash(extractedText(i.currentCompany))}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(i.city || i.residingCity)}</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(extractedText(i.education))}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div>
|
||||
</div>
|
||||
{i.sentAt && (
|
||||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDateTime(i.sentAt)}</div></div>
|
||||
)}
|
||||
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
|
||||
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||||
{i.atsScore != null && (
|
||||
|
|
@ -2915,13 +2853,11 @@ function ApplicationDetail({
|
|||
gap: 18,
|
||||
alignItems: 'start',
|
||||
marginBottom: 20,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: '1 1 0', minWidth: 0, maxWidth: '100%' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
{!loading && (
|
||||
<div className="email-pane" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||||
{looksLikeHtml(i.bodyHtml) ? (
|
||||
<EmailBody html={i.bodyHtml} />
|
||||
|
|
@ -2932,14 +2868,6 @@ function ApplicationDetail({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{resumeText ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Extracted CV</div>
|
||||
<pre className="resume-thumb is-full" style={{ maxHeight: 280, overflow: 'auto' }}>
|
||||
{resumeText}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
|
|
@ -3091,30 +3019,9 @@ function ApplicationDetail({
|
|||
)
|
||||
}
|
||||
|
||||
function AssignRecruiter({ item, toast, onClose }) {
|
||||
const qc = useQueryClient()
|
||||
const recruitersQuery = useQuery({
|
||||
queryKey: qk.tasks.assignees(),
|
||||
queryFn: async () => {
|
||||
const res = await tasksApi.listAssignees()
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
const recruiters = recruitersQuery.data ?? []
|
||||
const [recruiterId, setRecruiterId] = useState(item.recruiterId || '')
|
||||
const save = useMutation({
|
||||
mutationFn: () => inboxApi.assignRecruiter(item.id, recruiterId || null),
|
||||
onSuccess: () => {
|
||||
toast(recruiterId ? `Recruiter assigned to ${item.name}` : `${item.name} unassigned from recruiter`, 'success')
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign recruiter'), 'error'),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.message(item.id) })
|
||||
},
|
||||
})
|
||||
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
|
||||
const [name, setName] = useState(item.recruiter)
|
||||
const current = recruiters.find((r) => r.name === item.recruiter)
|
||||
return (
|
||||
<Modal
|
||||
title="Assign Recruiter"
|
||||
|
|
@ -3123,25 +3030,19 @@ function AssignRecruiter({ item, toast, onClose }) {
|
|||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
Assign
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-field">
|
||||
<label htmlFor="inbox-assign-recruiter">Recruiter</label>
|
||||
<select
|
||||
id="inbox-assign-recruiter"
|
||||
value={recruiterId}
|
||||
onChange={(e) => setRecruiterId(e.target.value)}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{recruiters.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
<label>Recruiter</label>
|
||||
<select value={name} onChange={(e) => setName(e.target.value)}>
|
||||
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
|
||||
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ export default function Jobs() {
|
|||
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
|
||||
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
|
||||
{ key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' },
|
||||
{ key: 'recruiter', label: 'Recruiters', sortable: true, render: (j) => j.recruiter || '—' },
|
||||
{ key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' },
|
||||
{
|
||||
key: 'created', label: 'Created', sortable: true,
|
||||
sortValue: (j) => (j.created ? j.created.getTime() : 0),
|
||||
|
|
@ -527,113 +527,6 @@ function SearchSelect({
|
|||
)
|
||||
}
|
||||
|
||||
function sameIdList(a, b) {
|
||||
const x = [...(a || [])].map(String)
|
||||
const y = [...(b || [])].map(String)
|
||||
return x.length === y.length && x.every((id, i) => id === y[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip + search picker for more than one recruiter. Value is always an id list.
|
||||
*/
|
||||
function RecruiterMultiSelect({
|
||||
options = [],
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Search recruiters…',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef(null)
|
||||
const selectedIds = (value || []).map(String)
|
||||
const selected = selectedIds.map((id) => (
|
||||
options.find((o) => String(o.id) === id) || { id, name: 'Selected recruiter' }
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const filtered = options.filter((o) => {
|
||||
if (selectedIds.includes(String(o.id))) return false
|
||||
if (!term) return true
|
||||
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
|
||||
return hay.includes(term)
|
||||
})
|
||||
|
||||
function add(id) {
|
||||
const next = String(id)
|
||||
if (!next || selectedIds.includes(next)) return
|
||||
onChange([...selectedIds, next])
|
||||
setQ('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
onChange(selectedIds.filter((x) => x !== String(id)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="job-recruiter-multi" ref={root}>
|
||||
{selected.length > 0 && (
|
||||
<div className="job-recruiter-chips">
|
||||
{selected.map((o) => (
|
||||
<span className="job-recruiter-chip" key={o.id}>
|
||||
{o.name}
|
||||
<button
|
||||
type="button"
|
||||
className="job-recruiter-chip-x"
|
||||
aria-label={`Remove ${o.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => remove(o.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`dropdown${open ? ' open' : ''}`} style={{ width: '100%' }}>
|
||||
<input
|
||||
value={open ? q : ''}
|
||||
disabled={disabled || loading}
|
||||
placeholder={loading ? 'Loading…' : (selected.length ? 'Add another recruiter…' : placeholder)}
|
||||
autoComplete="off"
|
||||
onFocus={() => { setOpen(true); setQ('') }}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
||||
/>
|
||||
{open && !disabled && !loading && (
|
||||
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{filtered.length === 0 && (
|
||||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>
|
||||
{selected.length && !term ? 'All recruiters selected' : 'No matches'}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
key={o.id}
|
||||
className="dropdown-link"
|
||||
onClick={() => add(o.id)}
|
||||
>
|
||||
{o.name}
|
||||
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useManagerDirectory() {
|
||||
return useQuery({
|
||||
queryKey: qk.managers.directory(),
|
||||
|
|
@ -700,7 +593,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_ids: [],
|
||||
current_recruiter_id: '',
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
|
|
@ -787,8 +680,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
requirements: splitLines(v.requirements),
|
||||
optional_skills: splitLines(v.optional_skills),
|
||||
description: v.description.trim() || null,
|
||||
hiring_manager_id: v.hiring_manager_id || null,
|
||||
current_recruiter_ids: (v.current_recruiter_ids || []).filter(Boolean),
|
||||
hiring_manager_id: v.hiring_manager_id || undefined,
|
||||
current_recruiter_id: v.current_recruiter_id || undefined,
|
||||
requisition_id: v.requisition_id || undefined,
|
||||
}, imageFile)
|
||||
}
|
||||
|
|
@ -881,30 +774,32 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
onChange={(id) => form.setField('hiring_manager_id', id)}
|
||||
placeholder="Leave unassigned, or search…"
|
||||
placeholder="Search hiring managers…"
|
||||
disabled={busy}
|
||||
loading={managersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
{managersQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load hiring managers. You can still create the job without one.</p>
|
||||
<p className="text-muted text-sm">Could not load hiring managers.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiters</label>
|
||||
<RecruiterMultiSelect
|
||||
<label>Recruiter</label>
|
||||
<SearchSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={form.values.current_recruiter_ids}
|
||||
onChange={(ids) => form.setField('current_recruiter_ids', ids)}
|
||||
value={form.values.current_recruiter_id}
|
||||
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={busy}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
{recruitersQuery.isError && (
|
||||
<p className="text-muted text-sm">Recruiter list needs tasks.view — you can assign later.</p>
|
||||
|
|
@ -1084,7 +979,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
||||
description: j.description || '',
|
||||
hiring_manager_id: j.hiringManagerId || '',
|
||||
current_recruiter_ids: j.recruiterIds || (j.recruiterId ? [j.recruiterId] : []),
|
||||
current_recruiter_id: j.recruiterId || '',
|
||||
})
|
||||
|
||||
const assistContext = () => ({
|
||||
|
|
@ -1126,7 +1021,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
|
||||
description: form.values.description.trim() || null,
|
||||
hiring_manager_id: form.values.hiring_manager_id || null,
|
||||
current_recruiter_ids: (form.values.current_recruiter_ids || []).filter(Boolean),
|
||||
current_recruiter_id: form.values.current_recruiter_id || null,
|
||||
requisition_id: form.values.requisition_id || null,
|
||||
})
|
||||
}
|
||||
|
|
@ -1186,12 +1081,12 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
onChange={(id) => form.setField('hiring_manager_id', id)}
|
||||
placeholder="Leave unassigned, or search…"
|
||||
placeholder="Search hiring managers…"
|
||||
disabled={busy}
|
||||
loading={managersQuery.isPending}
|
||||
allowEmpty
|
||||
|
|
@ -1199,14 +1094,16 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiters</label>
|
||||
<RecruiterMultiSelect
|
||||
<label>Recruiter</label>
|
||||
<SearchSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={form.values.current_recruiter_ids}
|
||||
onChange={(ids) => form.setField('current_recruiter_ids', ids)}
|
||||
value={form.values.current_recruiter_id}
|
||||
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={busy}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
|
|
@ -1284,7 +1181,7 @@ function JobOwnership({ job, canEdit }) {
|
|||
<div style={SECTION_LABEL}>Ownership</div>
|
||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
{canEdit ? (
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
|
|
@ -1294,7 +1191,7 @@ function JobOwnership({ job, canEdit }) {
|
|||
if (String(next || '') === String(job.hiringManagerId || '')) return
|
||||
patch.mutate({ hiring_manager_id: next })
|
||||
}}
|
||||
placeholder="Leave unassigned, or search…"
|
||||
placeholder="Search hiring managers…"
|
||||
disabled={patch.isPending}
|
||||
loading={managersQuery.isPending}
|
||||
allowEmpty
|
||||
|
|
@ -1305,20 +1202,21 @@ function JobOwnership({ job, canEdit }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiters</label>
|
||||
<label>Recruiter</label>
|
||||
{canEdit ? (
|
||||
<RecruiterMultiSelect
|
||||
<SearchSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={job.recruiterIds || (job.recruiterId ? [job.recruiterId] : [])}
|
||||
onChange={(ids) => {
|
||||
const next = (ids || []).filter(Boolean).map(String)
|
||||
const current = job.recruiterIds || (job.recruiterId ? [String(job.recruiterId)] : [])
|
||||
if (sameIdList(next, current)) return
|
||||
patch.mutate({ current_recruiter_ids: next })
|
||||
value={job.recruiterId || ''}
|
||||
onChange={(id) => {
|
||||
const next = id || null
|
||||
if (String(next || '') === String(job.recruiterId || '')) return
|
||||
patch.mutate({ current_recruiter_id: next })
|
||||
}}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={patch.isPending}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
|
||||
|
|
@ -1581,7 +1479,7 @@ function JobDetail({
|
|||
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
with no source is dropped rather than rendered as blanks.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
|
|
@ -27,11 +27,9 @@ 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 = [
|
||||
{ name: 'CLOSED', color: 'var(--stage-9)' },
|
||||
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
||||
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||
|
|
@ -45,7 +43,6 @@ export const KANBAN_STAGES = [
|
|||
|
||||
const BOARD_LIMIT = 200
|
||||
const JOB_LIMIT = 100
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Highest AI score first, unscored candidates last, newest first within a tie.
|
||||
|
|
@ -76,12 +73,8 @@ function mapCards(rows, mapper) {
|
|||
return cards
|
||||
}
|
||||
|
||||
async function fetchBoard(jobId, search) {
|
||||
const res = await pipelineApi.listApplications({
|
||||
jobId,
|
||||
limit: BOARD_LIMIT,
|
||||
search: search || undefined,
|
||||
})
|
||||
async function fetchBoard(jobId) {
|
||||
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
|
||||
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
|
||||
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
|
||||
return {
|
||||
|
|
@ -120,24 +113,17 @@ export default function Pipeline() {
|
|||
const qc = useQueryClient()
|
||||
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [overStage, setOverStage] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
const boardKey = useMemo(
|
||||
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null, search: search || null }),
|
||||
[jobId, search],
|
||||
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }),
|
||||
[jobId],
|
||||
)
|
||||
|
||||
const board = useQuery({
|
||||
queryKey: boardKey,
|
||||
queryFn: () => fetchBoard(jobId, search),
|
||||
queryFn: () => fetchBoard(jobId),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
|
|
@ -235,15 +221,6 @@ export default function Pipeline() {
|
|||
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
|
||||
</>}
|
||||
actions={<>
|
||||
<div className="toolbar-search" style={{ minWidth: 220 }}>
|
||||
<Icon name="search" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search candidate name or email…"
|
||||
aria-label="Search candidates"
|
||||
/>
|
||||
</div>
|
||||
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
<option value="">All Jobs</option>
|
||||
{jobs.map((j) => (
|
||||
|
|
@ -299,7 +276,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
|
||||
openCandidateProfile(navigate, c.userId, candidates)
|
||||
navigate(`/candidate/${c.userId}`)
|
||||
}}
|
||||
>
|
||||
<div className="k-card-top">
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@ export default function Progress() {
|
|||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [sortBy, setSortBy] = useState('applicants')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
|
||||
|
|
@ -240,11 +239,9 @@ export default function Progress() {
|
|||
|
||||
const filtered = useMemo(() => {
|
||||
const term = query.trim().toLowerCase()
|
||||
const sortFilter = sortBy === 'completed' ? 'completed' : status
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
const req = String(job.requisitionStatus || '').toLowerCase()
|
||||
if (sortFilter !== 'all' && req !== sortFilter) {
|
||||
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) {
|
||||
return false
|
||||
}
|
||||
if (!term) return true
|
||||
|
|
@ -256,7 +253,7 @@ export default function Progress() {
|
|||
)
|
||||
})
|
||||
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
|
||||
}, [jobs, query, status, sortBy])
|
||||
}, [jobs, query, status])
|
||||
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
|
@ -267,7 +264,7 @@ export default function Progress() {
|
|||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [query, status, sortBy])
|
||||
}, [query, status])
|
||||
|
||||
useEffect(() => {
|
||||
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
|
||||
|
|
@ -375,17 +372,8 @@ export default function Progress() {
|
|||
<option value="open">Open</option>
|
||||
<option value="on_hold">On hold</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
aria-label="Sort applicants"
|
||||
>
|
||||
<option value="applicants">Sort: applicants</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
<span className="progress-sort-chip">Sort: applicants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const RANGES = [
|
|||
]
|
||||
|
||||
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||
the start. REJECTED, CLOSED (shown as CLOSED) and ONHOLD are
|
||||
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
|
||||
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
||||
const FUNNEL_ORDER = [
|
||||
{ key: 'PENDING', label: 'Shortlist' },
|
||||
|
|
|
|||
|
|
@ -275,9 +275,6 @@ function blankForm() {
|
|||
date_needed: '',
|
||||
type: '',
|
||||
job_description: '',
|
||||
period_from: '',
|
||||
period_to: '',
|
||||
jd_available: false,
|
||||
to_replace: '',
|
||||
grade: '',
|
||||
recruitment_title: '',
|
||||
|
|
@ -287,7 +284,6 @@ function blankForm() {
|
|||
recommended_grade: '',
|
||||
employee_name: '',
|
||||
employee_department: '',
|
||||
entity: '',
|
||||
initiated_by: '',
|
||||
initiated_date: '',
|
||||
recommended_by: '',
|
||||
|
|
@ -314,9 +310,6 @@ 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 || '',
|
||||
|
|
@ -326,7 +319,6 @@ 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 || '',
|
||||
|
|
@ -338,7 +330,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', 'entity']),
|
||||
is_referral: hasAny(ref, ['employee_name', 'employee_department']),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,9 +343,6 @@ 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),
|
||||
|
|
@ -388,12 +377,10 @@ 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
|
||||
|
|
@ -481,22 +468,6 @@ 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
|
||||
|
|
@ -506,16 +477,6 @@ 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>
|
||||
|
||||
|
|
@ -603,10 +564,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
/* 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; }
|
||||
}
|
||||
|
|
@ -835,30 +835,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.tag { font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--text-2); }
|
||||
|
||||
.job-recruiter-multi { display: flex; flex-direction: column; gap: 8px; }
|
||||
.job-recruiter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.job-recruiter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.job-recruiter-chip-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.job-recruiter-chip-x:disabled { cursor: default; opacity: 0.5; }
|
||||
|
||||
/* ================= MISC ================= */
|
||||
.list-tight > * + * { border-top: 1px solid var(--border); }
|
||||
.list-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; }
|
||||
|
|
@ -1023,7 +999,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
/* Split inbox layout */
|
||||
.split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; }
|
||||
.split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); }
|
||||
.split-detail { overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; }
|
||||
.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; }
|
||||
/* The detail pane can be narrow while the viewport is wide (split layout),
|
||||
so viewport media queries cannot see it: the pane is a size container and
|
||||
its two-column field grid collapses on the pane's own width. */
|
||||
|
|
@ -1324,57 +1300,10 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
|
||||
/* Email viewer: a header strip joined to the body below it, Outlook-style. The
|
||||
body is either an iframe (HTML mail, see ui/EmailBody.jsx) or a <pre> for
|
||||
plain text — both square off their top corners to meet the header. Height
|
||||
follows the text; EmailBody measures the frame so HTML mail is not a 420px
|
||||
empty well. */
|
||||
.email-pane {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-head {
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
background: var(--bg-elev);
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.email-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0 0 10px 10px;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.email-plain {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
overflow-x: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
plain text — both square off their top corners to meet the header. */
|
||||
.email-head { border: 1px solid var(--border); border-bottom: none; border-radius: 10px 10px 0 0; background: var(--bg-elev); padding: 10px 14px; font-weight: 600; color: var(--text); font-size: 13px; overflow-wrap: break-word; }
|
||||
.email-frame { display: block; width: 100%; border: 1px solid var(--border); border-radius: 0 0 10px 10px; background: var(--bg-sunken); }
|
||||
.email-plain { border-radius: 0 0 10px 10px; }
|
||||
|
||||
/* Upload dropzone */
|
||||
/* Job detail cover image — banner above the info grid. */
|
||||
|
|
|
|||
|
|
@ -68,22 +68,6 @@ function sanitize(html) {
|
|||
a.setAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
// Outlook templates pin pixel widths (width="720", min-width:600px). Those
|
||||
// stretch the iframe past the detail pane. Drop the pins; CSS max-width
|
||||
// keeps the mail inside the box.
|
||||
doc.querySelectorAll('table, td, th, img, col').forEach((el) => {
|
||||
el.removeAttribute('width')
|
||||
if (el.tagName === 'IMG') el.removeAttribute('height')
|
||||
})
|
||||
doc.querySelectorAll('[style]').forEach((el) => {
|
||||
const style = el.getAttribute('style')
|
||||
if (!style) return
|
||||
el.setAttribute(
|
||||
'style',
|
||||
style.replace(/(?:min-|max-)?width\s*:\s*\d+(?:\.\d+)?px\s*;?/gi, ''),
|
||||
)
|
||||
})
|
||||
|
||||
return doc.body?.innerHTML || ''
|
||||
}
|
||||
|
||||
|
|
@ -101,39 +85,18 @@ function frameStyles() {
|
|||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
return `
|
||||
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: ${pick('--bg-sunken', '#f7f7f8')};
|
||||
color: ${pick('--text', '#111')};
|
||||
font-family: ${pick('--sans', 'system-ui, sans-serif')};
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
img, svg, video, canvas {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
td, th, p, div, span, li, a, pre, code, h1, h2, h3, h4, h5, h6 {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
pre, code { white-space: pre-wrap !important; }
|
||||
img, table { max-width: 100%; }
|
||||
img { height: auto; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: ${pick('--primary', '#2563eb')}; }
|
||||
blockquote {
|
||||
margin: 8px 0; padding-left: 12px;
|
||||
|
|
@ -160,12 +123,10 @@ export function looksLikeHtml(value) {
|
|||
return /<[a-z!/][\s\S]*>/i.test(String(value || ''))
|
||||
}
|
||||
|
||||
const MIN_FRAME_HEIGHT = 48
|
||||
|
||||
export default function EmailBody({ html, maxHeight }) {
|
||||
const ref = useRef(null)
|
||||
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
|
||||
const [height, setHeight] = useState(MIN_FRAME_HEIGHT)
|
||||
const [height, setHeight] = useState(320)
|
||||
const [blockedImages, setBlockedImages] = useState(0)
|
||||
const themeVersion = useThemeVersion()
|
||||
|
||||
|
|
@ -186,17 +147,9 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
const measure = useCallback(() => {
|
||||
const frame = ref.current
|
||||
// contentDocument is readable only because the sandbox keeps allow-same-origin.
|
||||
const doc = frame?.contentDocument
|
||||
const body = doc?.body
|
||||
const body = frame?.contentDocument?.body
|
||||
if (!body) return
|
||||
const htmlEl = doc.documentElement
|
||||
htmlEl.style.height = 'auto'
|
||||
body.style.height = 'auto'
|
||||
const next = Math.ceil(Math.max(body.scrollHeight, htmlEl.scrollHeight || 0))
|
||||
setHeight((prev) => {
|
||||
const value = Math.max(MIN_FRAME_HEIGHT, next)
|
||||
return prev === value ? prev : value
|
||||
})
|
||||
setHeight(body.scrollHeight + 8)
|
||||
}, [])
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
|
|
@ -211,27 +164,13 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure))
|
||||
}, [measure, allowRemoteImages])
|
||||
|
||||
useEffect(() => {
|
||||
setHeight(MIN_FRAME_HEIGHT)
|
||||
}, [html])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [measure])
|
||||
|
||||
useEffect(() => {
|
||||
const doc = ref.current?.contentDocument
|
||||
const body = doc?.body
|
||||
if (!body || typeof ResizeObserver === 'undefined') return undefined
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(body)
|
||||
if (doc.documentElement) ro.observe(doc.documentElement)
|
||||
return () => ro.disconnect()
|
||||
}, [srcDoc, measure])
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0, maxWidth: '100%', overflow: 'hidden' }}>
|
||||
<div>
|
||||
{blockedImages > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-8"
|
||||
|
|
@ -261,13 +200,7 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
// No allow-scripts. Ever. See the header comment.
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
srcDoc={srcDoc}
|
||||
scrolling="no"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: maxHeight ? Math.min(height, maxHeight) : height,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -147,12 +147,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
|
|||
)
|
||||
}
|
||||
|
||||
export function PickRoleModal({
|
||||
onClose,
|
||||
onPick,
|
||||
title = 'Choose a different role',
|
||||
subtitle = 'Search open job posts',
|
||||
}) {
|
||||
export function PickRoleModal({ onClose, onPick }) {
|
||||
const [q, setQ] = useState('')
|
||||
const { data = [], isPending, isError, error } = useQuery({
|
||||
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
|
||||
|
|
@ -164,8 +159,8 @@ export function PickRoleModal({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
title="Choose a different role"
|
||||
subtitle="Search open job posts"
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export function Tabs({ tabs, value, onChange, className = 'tabs', idBase }) {
|
|||
aria-controls={`${base}-panel-${i}`}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`tab${active ? ' active' : ''}`}
|
||||
data-tip={t.title || undefined}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
{label}{t.count != null && typeof t.count !== 'object' && (
|
||||
|
|
|
|||
|
|
@ -193,9 +193,7 @@ 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