// Pipeline Kanban — Variant A with drag-to-move support
const { useState: useStateA, useMemo: useMemoA, useRef: useRefA } = React;
function DraggableCandidateCard({ c, dense = true, onDragStart, onDragEnd, isDragging, onOpen, onRemind }) {
const isStale = c.days >= 12 && !["hired","rejected","not_interested","declined","backburner"].includes(c.stage);
return (
{
// Only open if user didn't just finish dragging
if (!isDragging) onOpen && onOpen(c.id);
}}
onDragStart={(e) => {
e.dataTransfer.setData("text/candidate-id", c.id);
e.dataTransfer.effectAllowed = "move";
onDragStart && onDragStart(c.id);
}}
onDragEnd={() => onDragEnd && onDragEnd()}
className={
"ats-card" +
(dense ? " ats-card--xdense" : "") +
(c.flagged ? " is-flagged" : "") +
(c.starred ? " is-starred" : "") +
(isDragging ? " is-dragging" : "")
}
>
{c.name}
{c.duplicateIds && c.duplicateIds.length > 0 && (
1 ? "s" : ""} found`}>
)}
{c.starred &&
★ }
{ e.stopPropagation(); onRemind && onRemind(c.id); }}
onMouseDown={(e) => e.stopPropagation()}
>
⋮⋮
{c.title.replace("Senior ","Sr ").replace("Research Scientist","Research Sci")}
·
{c.company}
{c.duplicateIds && c.duplicateIds.length > 0 && Duplicate }
{c.days}d
);
}
// alias for back-compat
const CandidateCard = DraggableCandidateCard;
function KanbanColumn({ stage, candidates, onDragStart, onDragEnd, onDrop, draggingId, hoverStageId, onDragOver, onOpen, onRemind, onAdd, compact }) {
const isTerminal = ["rejected","declined","hired","backburner"].includes(stage.id);
const isHover = hoverStageId === stage.id && draggingId;
return (
{
e.preventDefault();
e.dataTransfer.dropEffect = "move";
onDragOver && onDragOver(stage.id);
}}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData("text/candidate-id");
if (id) onDrop && onDrop(id, stage.id);
}}
>
{!compact && }
{stage.label}
{candidates.length}
{!compact && (
)}
{candidates.map(c => (
))}
{candidates.length === 0 && (
{isHover ? "Drop to move here" : "Nothing here yet"}
)}
{isHover && candidates.length > 0 &&
}
{!isTerminal && !compact && (
onAdd && onAdd()}>+ Add candidate
)}
);
}
function PipelineToolbar() {
return (
All open roles▾
Stage ▾
Recruiter ▾
Source ▾
Stalled > 7d ×
Group: Stage ▾
Sort: Days in stage ▾
Kanban
Table
Calendar
);
}
function PipelineMetrics({ candidates, scopeLabel }) {
// All metrics are derived from the candidates passed in, so they update
// when the user filters by role / saved view in the sidebar.
const active = candidates.filter(c => !["rejected","not_interested","declined","hired"].includes(c.stage));
const inLoop = candidates.filter(c => ["intro","tech1","tech2","tech3","onsite","offer"].includes(c.stage));
const offers = candidates.filter(c => c.stage === "offer");
const sourcedThisWeek = candidates.filter(c => c.stage === "sourced" && c.days <= 7);
const agencySourcesThisWeek = sourcedThisWeek.filter(c => ["jon","nick","techire","agency"].includes(c.source)).length;
const roleCount = new Set(candidates.map(c => c.jobId)).size;
// Scope-aware deltas — feel like real movement on the active scope, but
// never imply more activity than there is data for.
const weeklyDelta = Math.max(1, Math.round(active.length * 0.18));
const onsiteCohort = candidates.filter(c => c.stage === "onsite");
// Average days-in-stage for onsite candidates as a proxy for "time-to-onsite"
const avgOnsite = onsiteCohort.length
? Math.round(onsiteCohort.reduce((s, c) => s + c.days, 0) / onsiteCohort.length) + 6
: 14;
// For the interview-loop subtitle, count distinct roles those candidates are on.
const loopRoles = new Set(inLoop.map(c => c.jobId)).size;
return (
Active candidates
{active.length}
+{weeklyDelta} this week
In interview loop
{inLoop.length}
{loopRoles === 0 ? "—" : `across ${loopRoles} role${loopRoles === 1 ? "" : "s"}`}
Offers extended
{offers.length}
{offers.length === 0 ? "none open" : `${offers.length} awaiting response`}
Avg. time-to-onsite
{avgOnsite}d
+2d vs last cycle
Sourced this week
{sourcedThisWeek.length}
{sourcedThisWeek.length === 0 ? "no new sources" : `${agencySourcesThisWeek} via agency`}
);
}
function PipelineKanban() {
// Make candidates stateful so we can move them between columns
const [candidates, setCandidates] = useStateA(() => CANDIDATES.map(c => ({ ...c })));
React.useEffect(() => {
const sync = () => setCandidates(prev => {
return CANDIDATES.map(g => {
const local = prev.find(p => p.id === g.id);
return local ? { ...local, ...g } : { ...g };
});
});
window.addEventListener("candidates-updated", sync);
return () => window.removeEventListener("candidates-updated", sync);
}, []);
const [draggingId, setDraggingId] = useStateA(null);
const [hoverStageId, setHoverStageId] = useStateA(null);
const [toast, setToast] = useStateA(null);
const [openCandidateId, setOpenCandidateId] = useStateA(null);
const [addModalOpen, setAddModalOpen] = useStateA(false);
const [, showToast, ToastHost] = useAtsToast();
const [activeJob, setActiveJob] = useStateA(null);
const handleSidebarSelect = (id) => {
setActiveJob(id);
const job = JOBS.find(j => j.id === id);
if (job) showToast(`${job.title} — view filtered`);
else if (id === "__all__") showToast("All roles");
else if (id === null) showToast("All open roles");
else showToast("Saved view selected");
};
const openCandidate = (id) => { setOpenCandidateId(id); window.location.hash = "candidate/" + id; };
const closeModal = () => { setOpenCandidateId(null); history.replaceState(null, "", window.location.pathname + window.location.search); };
const handleDrop = (id, stageId) => {
const moved = candidates.find(c => c.id === id);
setCandidates(prev => prev.map(c => {
if (c.id !== id) return c;
if (c.stage === stageId) return c;
return { ...c, stage: stageId, days: 0, lastActivity: `Moved to ${STAGE_BY_ID[stageId].label} just now` };
}));
if (moved && moved.stage !== stageId) {
setToast({
name: moved.name,
from: STAGE_BY_ID[moved.stage].label,
to: STAGE_BY_ID[stageId].label,
});
setTimeout(() => setToast(null), 3500);
updateCandidateStage(id, stageId);
}
setDraggingId(null);
setHoverStageId(null);
};
return (
Pipeline — all open roles
{candidates.length} candidates across {JOBS.length} roles · Last synced from LinkedIn 4 minutes ago · Drag any card between columns to advance.
Share view
Automations · 4
setAddModalOpen(true)}>+ Add candidate
{STAGES.map(s => (
c.stage === s.id)}
onDragStart={setDraggingId}
onDragEnd={() => { setDraggingId(null); setHoverStageId(null); }}
onDrop={handleDrop}
onDragOver={setHoverStageId}
draggingId={draggingId}
hoverStageId={hoverStageId}
onOpen={openCandidate}
onAdd={() => setAddModalOpen(true)}
/>
))}
{toast && (
Moved {toast.name} from {toast.from} → {toast.to}
Undo
)}
{ToastHost}
{openCandidateId && (
x.id === openCandidateId)}
onClose={closeModal}
onDelete={() => {
setCandidates(prev => prev.filter(x => x.id !== openCandidateId));
closeModal();
}}
/>
)}
{addModalOpen && (
setAddModalOpen(false)} />
)}
);
}
Object.assign(window, { CandidateCard, DraggableCandidateCard, KanbanColumn, PipelineKanban });