/* Inbox, Candidates, Requisitions, Interviews, Reports pages */ /* ─────────────── shared page shell ─────────────── */ /* Hash helpers — view + optional id after `/` (e.g. "candidates/abc-123") */ function _parseHash() { const h = window.location.hash.replace(/^#/, ''); const [view, id] = h.split('/'); return { view: view || 'dashboard', id: id || null }; } function _setHashId(view, id) { window.location.hash = id ? `${view}/${id}` : view; } function PageShell({ active, onNav, title, children, onSearch, onSettings }) { const [searchOpen, setSearchOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [candidate, setCandidate] = React.useState(null); // Use the rich Kanban drawer if loaded — same UX everywhere; fall back to // the simpler CandidateDrawer only if pipeline.jsx hasn't initialized yet. const PD = window.PipelineDrawer; const oppId = candidate?.id || null; // Sync candidate state ↔ URL hash for shareable links. const openCandidate = React.useCallback((c) => { setCandidate(c); _setHashId(active, c?.id || null); }, [active]); const closeCandidate = React.useCallback(() => { setCandidate(null); _setHashId(active, null); }, [active]); // On mount + on hashchange, sync drawer state from URL — supports shared links. React.useEffect(() => { const sync = () => { const { view, id } = _parseHash(); if (view !== active) return; if (id && (!candidate || candidate.id !== id)) { setCandidate({ id }); } else if (!id && candidate) { setCandidate(null); } }; sync(); window.addEventListener('hashchange', sync); return () => window.removeEventListener('hashchange', sync); }, [active, candidate]); React.useEffect(() => { const fn = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setSearchOpen(true); } if (e.key === 'Escape') { setSearchOpen(false); setSettingsOpen(false); closeCandidate(); } }; window.addEventListener('keydown', fn); return () => window.removeEventListener('keydown', fn); }, [closeCandidate]); // PipelineDrawer hands us either an opp object {id, ...} or a bare id — // normalize before hitting the API. const _oppId = (x) => (x && typeof x === 'object' ? x.id : x); const movePipelineCandidate = React.useCallback((oppOrId, toStageId) => { const id = _oppId(oppOrId); if (!id) return; fetch(`/api/opportunities/${id}/stage`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stageId: toStageId }), }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); closeCandidate(); window.location.reload(); }) .catch(err => { console.error('[ATS] stage move failed', err); alert('Stage move failed — see console.'); }); }, [closeCandidate]); const rejectPipelineCandidate = React.useCallback((oppOrId) => { if (!confirm('Are you sure?')) return; movePipelineCandidate(oppOrId, 'rejected'); }, [movePipelineCandidate]); return (
setSearchOpen(true)} onSettings={() => setSettingsOpen(true)} />
{typeof children === 'function' ? children({ openCandidate }) : children}
setSearchOpen(false)} onOpenCandidate={openCandidate} /> setSettingsOpen(false)} /> {PD && oppId ? ( ) : ( )}
); } /* ─────────────── INBOX ─────────────── */ function InboxPage({ onNav }) { const [filter, setFilter] = React.useState('all'); const FILTERS = [ { id: 'all', label: 'All', match: () => true }, { id: 'offers', label: 'Offers', match: (u) => u.t.startsWith('offer') }, { id: 'interviews', label: 'Interviews', match: (u) => u.t === 'interview' }, { id: 'stage', label: 'Stage changes', match: (u) => u.t === 'stage' }, ]; /* repeat UPDATES a few times to simulate a longer inbox */ const allItems = [ ...UPDATES, { t: 'offer-sent', who: 'Aaron Lee', what: 'offer sent · Operations Lead, CS', when: '2d', tone: 'brand' }, { t: 'stage', who: 'Valentina Cruz', what: 'moved to Screening · Product Designer', when: '2d', tone: 'neutral' }, { t: 'offer-decline', who: 'Renata Oliveira', what: 'declined offer · Product Manager, Risk', when: '3d', tone: 'dest' }, { t: 'interview', who: 'Pooja Iyer', what: 'interview scheduled · Data Analyst', when: '3d', tone: 'brand' }, { t: 'stage', who: 'Jonas Weber', what: 'moved to Applied · Senior Backend', when: '4d', tone: 'neutral' }, { t: 'offer-accept', who: 'Thiago Almeida', what: 'accepted offer · Frontend Engineer', when: '5d', tone: 'prog' }, ]; const visible = allItems.filter(FILTERS.find(f => f.id === filter)?.match || (() => true)); const ICON_MAP = { 'offer-accept': { icon: , tone: 'prog' }, 'offer-sent': { icon: , tone: 'brand' }, 'offer-decline': { icon: , tone: 'dest' }, 'interview': { icon: , tone: 'brand' }, 'stage': { icon: , tone: 'neutral' }, }; const toneColors = { brand: { bg: 'var(--indigo-20)', fg: 'var(--indigo-90)' }, prog: { bg: 'var(--green-20)', fg: 'var(--green-90)' }, dest: { bg: 'var(--red-20)', fg: 'var(--red-90)' }, neutral: { bg: 'var(--neutral-20)',fg: 'var(--neutral-80)' }, }; return (

Inbox

{visible.length} notifications
{FILTERS.map(f => ( setFilter(f.id)}> {f.label} ))}
{visible.length === 0 && (
Nothing here yet.
)} {visible.map((u, i) => { const ic = ICON_MAP[u.t] || ICON_MAP['stage']; const tc = toneColors[ic.tone] || toneColors.neutral; return (
e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{ic.icon}
{u.who} {u.what}
{u.when} ago
); })}
); } /* ─────────────── CANDIDATES ─────────────── */ function CandidatesPage({ onNav }) { const [search, setSearch] = React.useState(''); const [stageFilter, setStageFilter] = React.useState('all'); const [reqFilter, setReqFilter] = React.useState('all'); const [sort, setSort] = React.useState('score'); const stages = ['all', ...Array.from(new Set(CANDIDATES.map(c => c.stage)))]; const reqOptions = [ { value: 'all', label: 'All jobs' }, ...REQS.map(r => ({ value: r.id, label: r.title })), ]; const filtered = React.useMemo(() => { let list = [...CANDIDATES]; if (search.trim()) { const q = search.toLowerCase(); list = list.filter(c => c.name.toLowerCase().includes(q) || (c.roleLabel || roleLabel(c.role)).toLowerCase().includes(q) || c.loc.toLowerCase().includes(q) || c.stage.toLowerCase().includes(q) ); } if (stageFilter !== 'all') list = list.filter(c => c.stage === stageFilter); if (reqFilter !== 'all') { const role = reqById(reqFilter)?.role; list = list.filter(c => c.role === role); } if (sort === 'score') list.sort((a, b) => b.score - a.score); if (sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name)); if (sort === 'recent') list.sort((a, b) => parseFloat(a.lastTouch) - parseFloat(b.lastTouch)); return list; }, [search, stageFilter, reqFilter, sort]); const STAGE_TONE = { 'Applied': 'neutral', 'Screening': 'brand', 'Interview': 'brand', 'Take-home': 'warn', 'Offer': 'prog', 'Hired': 'prog', }; return ( {({ openCandidate }) => ( <>

Candidates

{filtered.length} of {CANDIDATES.length} candidates
{/* filters row */}
setSearch(e.target.value)} style={{ border: 0, outline: 0, background: 'transparent', flex: 1, minWidth: 0, fontSize: 13, fontFamily: 'var(--font-sans)', color: 'var(--content-primary)' }} /> {search && ( )}
} /> setSearch(e.target.value)} style={{ border: 0, outline: 0, background: 'transparent', flex: 1, fontSize: 13, fontFamily: 'var(--font-sans)', color: 'var(--content-primary)' }} />
{['all', 'on-track', 'at-risk'].map(f => ( setStatusFilter(f)}> {f === 'all' ? 'All' : f === 'on-track' ? 'On track' : 'At risk'} ))}
{/* header */}
{['Role', 'Department', 'Applicants', 'Days open', 'AI fill est.', 'Status'].map((h, i) => (
0 ? '1px solid var(--stroke-secondary)' : 'none' }}>{h}
))}
{visible.length === 0 && (
No jobs match.
)} {visible.map((r, i) => { const cands = candsForReq(r.id); return (
openReq(r)} onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{r.title}
{r.loc} · {r.hires} hired · Owner: {r.owner}
{r.dept}
{r.applicants}
r.target ? 'var(--yellow-70)' : 'var(--content-primary)', fontWeight: 600 }}>{r.days}d
r.target ? 'var(--yellow-70)' : 'var(--green-70)', fontWeight: 600 }}>~{r.predicted}d
{r.status}
); })}
{newPostingOpen && NewPostingModal && ( setNewPostingOpen(false)} onCreated={() => { setNewPostingOpen(false); window.location.reload(); }} /> )} {activeReq && ( window.location.reload()} /> )} )}
); } /* ─────────────── INTERVIEWS ─────────────── */ function InterviewsPage({ onNav }) { const interviewCandidates = CANDIDATES.filter(c => c.stage === 'Interview' || c.stage === 'Take-home'); const scheduled = [ { time: '09:00', candidate: CANDIDATES[0], type: 'Technical', interviewer: 'Carla R.', link: '#' }, { time: '11:30', candidate: CANDIDATES[3], type: 'Product review', interviewer: 'Diego F.', link: '#' }, { time: '14:00', candidate: CANDIDATES[8], type: 'Culture fit', interviewer: 'Pablo M.', link: '#' }, { time: '15:30', candidate: CANDIDATES[2], type: 'Take-home debrief', interviewer: 'Carla R.', link: '#' }, ].filter(s => s.candidate); const upcoming = [ { date: 'Tomorrow', candidate: CANDIDATES[2], type: 'Portfolio review', interviewer: 'Diego F.' }, { date: 'Wed 28 May', candidate: CANDIDATES[1], type: 'Technical', interviewer: 'Pablo M.' }, { date: 'Thu 29 May', candidate: CANDIDATES[3], type: 'Final round', interviewer: 'Carla R.' }, { date: 'Fri 30 May', candidate: CANDIDATES[8], type: 'Final round', interviewer: 'Carla R.' }, ].filter(u => u.candidate); return ( {({ openCandidate }) => ( <>

Interviews

{scheduled.length} scheduled today · {upcoming.length} upcoming
{/* Today */}
Today · Wed 21 May
{scheduled.map((s, i) => (
openCandidate(s.candidate)} onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--stroke-primary)'} onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--stroke-secondary)'}>
{s.time}
{s.candidate.name}
{s.type} · with {s.interviewer}
))}
{/* Upcoming */}
Upcoming
{upcoming.map((u, i) => (
openCandidate(u.candidate)} onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--stroke-primary)'} onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--stroke-secondary)'}>
{u.date}
{u.candidate.name}
{u.type} · with {u.interviewer}
))}
{/* Candidates in interview/take-home */}
In-process candidates ({interviewCandidates.length})
{interviewCandidates.map(c => (
openCandidate(c)} style={{ background: 'var(--bg-primary)', border: '1px solid var(--stroke-secondary)', borderRadius: 14, padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'center', cursor: 'pointer', transition: 'border-color 120ms' }} onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--stroke-primary)'} onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--stroke-secondary)'}>
{c.name}
{c.roleLabel || roleLabel(c.role)} · {c.loc}
{c.stage}
))}
)} ); } /* ─────────────── REPORTS ─────────────── */ function ReportsPage({ onNav }) { const [period, setPeriod] = React.useState('month'); const periodObj = TIME_PERIODS.find(p => p.id === period); const kpiDefs = [ { id: 'ttf', label: 'Time to fill', fmt: (v) => `${Math.round(v)}d`, target: 30, lowerBetter: true }, { id: 'tth', label: 'Time to hire', fmt: (v) => `${v.toFixed(1)}d`, target: 7, lowerBetter: true }, { id: 'offer', label: 'Offer acceptance', fmt: (v) => `${Math.round(v)}%`, target: 90, lowerBetter: false }, { id: 'screen', label: 'Screening approval', fmt: (v) => `${Math.round(v)}%`, target: 60, lowerBetter: false }, { id: 'attr', label: 'Attrition', fmt: (v) => `${v.toFixed(1)}%`, target: 5, lowerBetter: true }, { id: 'applied',label: 'Applicants', fmt: (v) => `${Math.round(v)}`, target: 40, lowerBetter: false }, ]; const kpis = kpiDefs.map(k => { const s = metricSeries(k.id, period, 'all'); const onTrack = k.lowerBetter ? s.current <= k.target : s.current >= k.target; return { ...k, ...s, tone: onTrack ? 'prog' : 'warn', display: k.fmt(s.current) }; }); return (

Reports

Hiring KPIs · all jobs