/* 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.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 && (
)}
{/* table */}
{/* header */}
{['Candidate', 'Role', 'Location', 'Stage', 'Last touch', 'Score'].map((h, i) => (
0 ? '1px solid var(--stroke-secondary)' : 'none' }}>{h}
))}
{filtered.length === 0 && (
No candidates match your filters.
)}
{filtered.map((c, i) => (
openCandidate(c)}
style={{ display: 'grid', gridTemplateColumns: '2fr 1.2fr 1fr 1fr 1fr auto', gap: 0,
borderBottom: i < filtered.length - 1 ? '1px solid var(--stroke-secondary)' : 'none',
cursor: 'pointer', transition: 'background 120ms' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{c.name}
{c.langs.join(' · ')}
{c.roleLabel || roleLabel(c.role)}
{c.loc}
{c.lastTouch}
))}
>
)}
);
}
/* ─────────────── REQUISITIONS ─────────────── */
function RequisitionDrawer({ reqSummary, onClose, onDeleted }) {
const [detail, setDetail] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (!reqSummary) return;
setLoading(true);
setDetail(null);
fetch(`/api/postings/${reqSummary.id}`)
.then(r => r.ok ? r.json() : null)
.then(d => setDetail(d))
.catch(() => {})
.finally(() => setLoading(false));
}, [reqSummary]);
const handleDelete = () => {
if (!reqSummary) return;
if (!confirm('Are you sure?')) return;
setDeleting(true);
fetch(`/api/postings/${reqSummary.id}`, { method: 'DELETE' })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(() => { onDeleted && onDeleted(reqSummary.id); onClose(); })
.catch(err => { console.error('[ATS] delete posting failed', err); alert('Delete failed — see console.'); })
.finally(() => setDeleting(false));
};
if (!reqSummary) return null;
return (
e.stopPropagation()} style={{
position: 'absolute', right: 0, top: 0, bottom: 0, width: 540,
background: 'var(--bg-primary)', boxShadow: 'var(--shadow-lg)',
display: 'flex', flexDirection: 'column'
}}>
{/* header */}
Job
{reqSummary.title}
{reqSummary.dept} · {reqSummary.loc} · Owner: {reqSummary.owner}
Copied
{/* body */}
{/* stats */}
{loading &&
Loading details…
}
{detail && (
<>
{detail.overview && (
Overview
{detail.overview}
)}
{detail.responsibilities && detail.responsibilities.length > 0 && (
Responsibilities
{detail.responsibilities.map((r, i) => - {r}
)}
)}
{detail.requirements && detail.requirements.length > 0 && (
Requirements
{detail.requirements.map((r, i) => - {r}
)}
)}
{detail.niceToHaves && detail.niceToHaves.length > 0 && (
Nice-to-haves
{detail.niceToHaves.map((r, i) => - {r}
)}
)}
{detail.criteria && detail.criteria.length > 0 && (
AI scoring criteria
{detail.criteria.map(c => (
{c.label}
· weight {c.weight}
))}
)}
>
)}
{/* footer */}
);
}
function RequisitionsPage({ onNav }) {
const { role } = React.useContext(RoleContext);
const [statusFilter, setStatusFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const [newPostingOpen, setNewPostingOpen] = React.useState(false);
const [activeReq, setActiveReq] = React.useState(null);
const NewPostingModal = window.NewPostingModal;
const openReq = React.useCallback((r) => {
setActiveReq(r);
_setHashId('reqs', r?.id || null);
}, []);
const closeReq = React.useCallback(() => {
setActiveReq(null);
_setHashId('reqs', null);
}, []);
// Sync drawer with URL hash for shareable links.
React.useEffect(() => {
const sync = () => {
const { view, id } = _parseHash();
if (view !== 'reqs') return;
if (id) {
const r = (window.REQS || []).find(x => x.id === id);
if (r && (!activeReq || activeReq.id !== id)) setActiveReq(r);
} else if (activeReq) {
setActiveReq(null);
}
};
sync();
window.addEventListener('hashchange', sync);
return () => window.removeEventListener('hashchange', sync);
}, [activeReq]);
const base = role === 'manager' ? REQS.filter(r => r.owner === 'Carla R.') : REQS;
const visible = base.filter(r => {
if (statusFilter === 'at-risk' && r.status !== 'At risk') return false;
if (statusFilter === 'on-track' && r.status !== 'On track') return false;
if (search.trim() && !r.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const atRisk = base.filter(r => r.status === 'At risk').length;
const onTrack = base.filter(r => r.status === 'On track').length;
return (
{({ openCandidate }) => (
<>
Jobs
{onTrack} on track · {atRisk} at risk
{/* summary cards */}
{[
{ label: 'Open reqs', value: base.length, tone: 'neutral' },
{ label: 'At risk', value: atRisk, tone: atRisk > 0 ? 'warn' : 'prog' },
{ label: 'Avg applicants', value: Math.round(base.reduce((a,r) => a + r.applicants, 0) / base.length), tone: 'neutral' },
].map(s => (
))}
{/* filter row */}
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
);
})}
{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
({ value: p.id, label: p.label }))}
icon={} />
{/* KPI grid */}
{kpis.map(k => (
{k.label}
{k.trendPct > 0 ? : k.trendPct < 0 ? : null}
{Math.abs(k.trendPct)}%
{k.display}
Target {k.fmt(k.target)}
{periodObj.binLabel(0)}
{periodObj.binLabel(k.bins - 1)}
))}
{/* per-req breakdown */}
Breakdown by requisition
{periodObj.label}
{['Requisition', 'Applicants', 'Time to fill', 'Time to hire', 'Status'].map((h, i) => (
0 ? '1px solid var(--stroke-secondary)' : 'none' }}>{h}
))}
{REQS.map((r, i) => {
const ttf = metricSeries('ttf', period, r.id);
const tth = metricSeries('tth', period, r.id);
return (
e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{r.title}
{r.dept} · {r.owner}
{r.applicants}
ttf.target ? 'var(--yellow-70)' : 'var(--green-70)', fontWeight: 600 }}>{Math.round(ttf.current)}d
tth.target ? 'var(--yellow-70)' : 'var(--green-70)', fontWeight: 600 }}>{tth.current.toFixed(1)}d
);
})}
);
}
window.InboxPage = InboxPage;
window.CandidatesPage = CandidatesPage;
window.RequisitionsPage = RequisitionsPage;
window.InterviewsPage = InterviewsPage;
window.ReportsPage = ReportsPage;