// Unified Pipeline screen — Notion-style view tabs (Kanban / Table / Calendar) // All views share the same candidates state, so a card moved in one view stays moved in another. const { useState: useStateU, useMemo: useMemoU } = React; // === Calendar view (interview schedule) === function PipelineCalendar({ candidates }) { // Build a fake April 2026 month grid (Apr 24, 2026 is "today") const dayHeaders = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; // Apr 1 2026 is a Wednesday → 2 leading blanks (Mon, Tue) const firstWeekdayOffset = 2; const daysInMonth = 30; const cells = []; for (let i = 0; i < firstWeekdayOffset; i++) cells.push({ off: true, date: 31 - firstWeekdayOffset + i + 1 }); for (let d = 1; d <= daysInMonth; d++) cells.push({ off: false, date: d }); while (cells.length % 7) cells.push({ off: true, date: cells.length - daysInMonth - firstWeekdayOffset + 1 }); // Distribute interview events deterministically across late April const interviewStages = ["intro","tech1","tech2","tech3","onsite"]; const eligible = candidates.filter(c => interviewStages.includes(c.stage)); const eventsByDay = {}; eligible.forEach((c, i) => { // Spread across days 14..29 (recent + upcoming) const day = 14 + (i * 3) % 16; (eventsByDay[day] ||= []).push(c); }); const today = 24; return (
{dayHeaders.map(d =>
{d}
)}
{cells.map((cell, i) => { const evs = !cell.off ? (eventsByDay[cell.date] || []) : []; return (
{cell.date}{!cell.off && cell.date === today && " · today"}
{evs.slice(0, 3).map(c => { const stage = STAGE_BY_ID[c.stage]; return (
{stage.short.toLowerCase()} · {c.name.split(" ")[0]} {c.last[0]}.
); })} {evs.length > 3 && (
+{evs.length - 3} more
)}
); })}
); } // === Table view (extracted from old PipelineSheet, parameterized) === // Sortable column definitions: how to extract a comparable value from a candidate row. const SHEET_SORT_KEYS = { name: (c) => c.name.toLowerCase(), stage: (c) => STAGE_FLOW.indexOf(c.stage), role: (c) => (JOBS.find(j => j.id === c.jobId)?.title || "").toLowerCase(), source: (c) => (c.sourceLabel || c.source || "").toLowerCase(), days: (c) => c.days ?? -Infinity, loc: (c) => (c.location || "").toLowerCase(), comp: (c) => c.salaryLo ?? -Infinity, owner: (c) => (c.recruiterName || "").toLowerCase(), activity:(c) => (c.lastActivity || "").toLowerCase(), }; function compareBy(getter, dir) { return (a, b) => { const x = getter(a), y = getter(b); if (x === y) return 0; const r = x < y ? -1 : 1; return dir === "desc" ? -r : r; }; } function SortHeader({ id, label, sort, onSort, align }) { // sort is an array of {key,dir}; we light up if id appears anywhere, but show priority badge if multi. const idx = (sort.keys || []).findIndex(s => s.key === id); const active = idx >= 0; const dir = active ? sort.keys[idx].dir : null; const indicator = !active ? "↕" : dir === "asc" ? "↑" : "↓"; const showPriority = active && sort.keys.length > 1; return ( onSort(id)} className={"ats-sheet__sort-th" + (active ? " is-active" : "")} style={{ cursor: "pointer", userSelect: "none", textAlign: align || "left" }} title={active ? (dir === "asc" ? "Sorted ascending — click to reverse" : "Sorted descending — click to clear") : "Click to sort ascending"} > {label} {indicator} {showPriority && ( {idx + 1} )} ); } // === Filter system === // Each column declares a filter "kind". Predicates are applied AND-style. const SHEET_FILTER_FIELDS = [ { id: "name", label: "Candidate", kind: "text", getter: (c) => c.name }, { id: "stage", label: "Stage", kind: "multi", getter: (c) => c.stage, options: () => STAGES.map(s => ({ value: s.id, label: s.label })) }, { id: "role", label: "Role", kind: "multi", getter: (c) => c.jobId, options: () => JOBS.map(j => ({ value: j.id, label: j.title })) }, { id: "source", label: "Source", kind: "multi", getter: (c) => c.source, options: () => SOURCES.map(s => ({ value: s.id, label: s.label })) }, { id: "dup", label: "Duplicate", kind: "multi", getter: (c) => c.duplicateIds && c.duplicateIds.length > 0 ? "yes" : "no", options: () => [{ value: "yes", label: "Duplicated" }, { value: "no", label: "Unique" }] }, { id: "days", label: "Days in stage", kind: "range", getter: (c) => c.days, min: 0, max: 30, step: 1 }, { id: "loc", label: "Location", kind: "text", getter: (c) => c.location }, { id: "owner", label: "Owner", kind: "multi", getter: (c) => c.recruiter, options: () => RECRUITERS.map(r => ({ value: r.id, label: r.name })) }, ]; const FILTER_FIELD_BY_ID = Object.fromEntries(SHEET_FILTER_FIELDS.map(f => [f.id, f])); function applyFilter(filter, c) { const f = FILTER_FIELD_BY_ID[filter.field]; if (!f) return true; const v = f.getter(c); if (f.kind === "text") { const q = (filter.value || "").trim().toLowerCase(); if (!q) return true; return String(v ?? "").toLowerCase().includes(q); } if (f.kind === "multi") { const sel = filter.value || []; if (!sel.length) return true; return sel.includes(v); } if (f.kind === "range") { const [lo, hi] = filter.value || [f.min, f.max]; return v != null && v >= lo && v <= hi; } return true; } function filterSummary(filter) { const f = FILTER_FIELD_BY_ID[filter.field]; if (!f) return ""; if (f.kind === "text") return filter.value ? `contains "${filter.value}"` : "any"; if (f.kind === "multi") { const sel = filter.value || []; if (!sel.length) return "any"; if (sel.length === 1) { const opt = f.options().find(o => o.value === sel[0]); return `is ${opt?.label || sel[0]}`; } return `is any of ${sel.length}`; } if (f.kind === "range") { const [lo, hi] = filter.value || [f.min, f.max]; if (lo === f.min && hi === f.max) return "any"; if (lo === f.min) return `≤ ${hi}`; if (hi === f.max) return `≥ ${lo}`; return `${lo}–${hi}`; } return ""; } function FilterChip({ filter, onChange, onRemove }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const f = FILTER_FIELD_BY_ID[filter.field]; if (!f) return null; return ( setOpen(o => !o)} style={{ cursor: "pointer" }}> {f.label} {filterSummary(filter)} × {open && } ); } function FilterPopover({ field, filter, onChange }) { if (field.kind === "text") { return (
onChange({ ...filter, value: e.target.value })} />
); } if (field.kind === "multi") { const opts = field.options(); const sel = new Set(filter.value || []); return (
{field.label} is any of
{opts.map(o => ( ))}
); } if (field.kind === "range") { const [lo, hi] = filter.value || [field.min, field.max]; return (
{field.label} between
onChange({ ...filter, value: [Math.min(+e.target.value, hi), hi] })} /> onChange({ ...filter, value: [lo, Math.max(+e.target.value, lo)] })} />
{field.min}–{field.max}
); } return null; } function AddFilterButton({ existing, onAdd }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const used = new Set(existing.map(f => f.field)); const available = SHEET_FILTER_FIELDS.filter(f => !used.has(f.id)); return ( {open && available.length > 0 && (
{available.map(f => ( ))}
)}
); } // Standalone filter bar — drives a shared filter list used by both Table and Board views. function PipelineFilterBar({ filters, setFilters, total, visibleCount, sort, setSortKeys, quickFilters, toggleQuickFilter }) { const addFilter = (field) => { const initial = field.kind === "text" ? "" : field.kind === "multi" ? [] : field.kind === "range" ? [field.min, field.max] : null; setFilters(prev => [...prev, { field: field.id, value: initial }]); }; const updateFilter = (idx, next) => setFilters(prev => prev.map((f, i) => i === idx ? next : f)); const removeFilter = (idx) => setFilters(prev => prev.filter((_, i) => i !== idx)); const clearAll = () => setFilters([]); const sortKeys = sort.keys || []; const addSort = (fieldId) => setSortKeys([...sortKeys, { key: fieldId, dir: "asc" }]); const removeSort = (idx) => setSortKeys(sortKeys.filter((_, i) => i !== idx)); const toggleSortDir = (idx) => setSortKeys(sortKeys.map((s, i) => i === idx ? { ...s, dir: s.dir === "asc" ? "desc" : "asc" } : s)); const moveSort = (idx, delta) => { const next = [...sortKeys]; const j = idx + delta; if (j < 0 || j >= next.length) return; [next[idx], next[j]] = [next[j], next[idx]]; setSortKeys(next); }; const clearSort = () => setSortKeys([]); const hasActivity = filters.length > 0 || sortKeys.length > 0; return (
); } function AddSortButton({ existing, onAdd }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const used = new Set(existing.map(s => s.key)); const available = SHEET_FILTER_FIELDS.filter(f => !used.has(f.id) && SHEET_SORT_KEYS[f.id]); return ( {open && available.length > 0 && (
{available.map(f => ( ))}
)}
); } function SortChip({ sort, index, total, onToggleDir, onRemove, onMoveUp, onMoveDown }) { const field = SHEET_FILTER_FIELDS.find(f => f.id === sort.key); const label = field ? field.label : sort.key; return ( {total > 1 && ( {index + 1} )} Sort: {total > 1 && onMoveUp && ( )} {total > 1 && onMoveDown && ( )} ); } function PipelineSheetView({ candidates, onOpen, sort, onSort }) { const handleSort = onSort; // Sorting is applied upstream in PipelineUnified so it persists across views. const sorted = candidates; return (
{sorted.map(c => { const isStale = c.days >= 12 && !["hired","rejected","not_interested","declined","backburner"].includes(c.stage); const job = JOBS.find(j => j.id === c.jobId); return ( onOpen && onOpen(c.id)} style={{ cursor: onOpen ? "pointer" : "default" }}> ); })}
{c.name} {c.starred && }{c.duplicateIds && c.duplicateIds.length > 0 && 1 ? "s" : ""}`} style={{ marginLeft: 4 }}>} {c.title} · {c.company}
{job ? job.title.replace("Real-time Systems", "RT Systems") : "—"} {c.duplicateIds && c.duplicateIds.length > 0 && Duplicate} {c.days}d {c.location} {c.salary} {c.lastActivity}
); } // === Sourced sub-columns by source === const SOURCED_SPLITS = [ { id: "inbound", label: "Inbound", match: (c) => c.source === "inbound" }, { id: "outbound", label: "Outbound", match: (c) => c.source === "outbound" }, { id: "paraform", label: "Paraform", match: (c) => c.source === "paraform" }, { id: "jon_luzha", label: "Jon Luzha", match: (c) => c.source === "Jon Luzha" }, ]; function SourcedGroup({ candidates, onDragStart, onDragEnd, onDrop, onDragOver, draggingId, hoverStageId, onOpen, onRemind, onAdd }) { const sourcedStage = STAGE_BY_ID["sourced"]; const matched = new Set(); const splits = SOURCED_SPLITS.map(sp => { const filtered = candidates.filter(c => { if (sp.match(c)) { matched.add(c.id); return true; } return false; }); return { ...sp, candidates: filtered }; }); const other = candidates.filter(c => !matched.has(c.id)); if (other.length > 0) splits.push({ id: "other", label: "Other", candidates: other }); return (
Sourced {candidates.length}
{splits.map(sp => ( ))}
); } // === Kanban view (extracted from old PipelineKanban) === function PipelineKanbanView({ candidates, onDragStart, onDragEnd, onDrop, onDragOver, draggingId, hoverStageId, onOpen, onRemind, onAdd }) { const sourcedCandidates = candidates.filter(c => c.stage === "sourced"); return (
{STAGES.filter(s => s.id !== "sourced").map(s => ( c.stage === s.id)} onDragStart={onDragStart} onDragEnd={onDragEnd} onDrop={onDrop} onDragOver={onDragOver} draggingId={draggingId} hoverStageId={hoverStageId} onOpen={onOpen} onRemind={onRemind} onAdd={onAdd} /> ))}
); } // === Notion-style view tabs === const PIPELINE_VIEWS_LABEL = { board: "Board view", table: "Table view", calendar: "Calendar view", }; const CalendarIcon = () => ( ); const ClockIcon = () => ( ); const SourceIcon = () => ( ); // Quick-filter chips that live in the filter bar (independent of the active view). // Each is a boolean toggle; multiple can be active at once and they AND together // with the column filters. const QUICK_FILTERS = [ { id: "stalled", label: "Stalled", desc: "In stage > 7 days", icon: , predicate: (c) => c.days > 7 && !["hired","rejected","not_interested","declined","backburner"].includes(c.stage), }, { id: "referrals", label: "Sourced by referral", desc: "Source = referral", icon: , predicate: (c) => c.source === "referral", }, { id: "duplicates", label: "Duplicated submissions", desc: "Has duplicate profiles", icon: , predicate: (c) => c.duplicateIds && c.duplicateIds.length > 0, }, ]; const VIEW_DEFS = [ { id: "board", label: "Board view", icon: "▦", desc: "Kanban grouped by stage" }, { id: "table", label: "Table view", icon: "≡", desc: "Grouped table · 30+ rows" }, { id: "calendar", label: "Calendar view", icon: , desc: "Interviews on a calendar" }, ]; function ViewTabs({ activeId, onSelect, onAction }) { return (
{VIEW_DEFS.map(v => ( ))}
); } // === Toolbar — view-specific controls === function ViewToolbar({ viewId, onAction }) { const [calMonthIdx, setCalMonthIdx] = useStateU(3); // 0=Jan…3=Apr 2026 const [calZoom, setCalZoom] = useStateU("Month"); const months = ["January 2026","February 2026","March 2026","April 2026","May 2026","June 2026"]; const [active, setActive] = useStateU({}); // filter id -> true const toggleFilter = (id, label) => { setActive(prev => { const n = { ...prev }; if (n[id]) delete n[id]; else n[id] = label; return n; }); onAction && onAction(active[id] ? `Cleared ${label}` : `Filter applied: ${label}`); }; if (viewId === "calendar") { return (
Stage: interviewing ×
{["Day","Week","Month"].map(z => ( ))}
); } const filters = [ { id: "stage", label: "Stage" }, { id: "recruiter", label: "Recruiter" }, { id: "source", label: "Source" }, ]; return (
{filters.map(f => ( ))}
{viewId === "stalled" && ( Days in stage > 7 × )} {viewId === "referrals" && ( Source = referral × )} {Object.entries(active).map(([id, label]) => ( {label}: any toggleFilter(id, label)} style={{ cursor: "pointer" }}>× ))}
); } function SharePopover({ url, onClose, onCopied }) { const inputRef = React.useRef(null); const popRef = React.useRef(null); React.useEffect(() => { // Auto-select the URL so user can ⌘C immediately if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } const onDoc = (e) => { if (popRef.current && !popRef.current.contains(e.target)) onClose(); }; const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [onClose]); const [copyState, setCopyState] = React.useState("idle"); // "idle" | "copied" | "manual" const handleCopy = async () => { // Try the modern async API first — it returns a Promise that REJECTS in // sandboxed iframes, so we must actually await it before claiming success. let copied = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(url); copied = true; } } catch (e) { copied = false; } // Fallback: select the visible input and try execCommand. This still // fails inside many sandboxed iframes, so we check the return value. if (!copied) { try { const el = inputRef.current; if (el) { el.focus(); el.select(); copied = !!document.execCommand("copy"); } } catch (e) { copied = false; } } if (copied) { setCopyState("copied"); setTimeout(() => onCopied(), 600); // brief "Copied" flash, then close + toast } else { // Programmatic copy is blocked (likely a sandboxed preview). // Keep the popover open with the URL selected so ⌘C / Ctrl+C works — // that path is allowed because it's a real user keystroke. if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } setCopyState("manual"); } }; return (
Share this view
Anyone with the link can open this pipeline view
e.target.select()} />
{copyState === "manual" && (
Clipboard access is blocked in this preview. The link is selected — press ⌘C (or Ctrl+C) to copy.
)}
); } // === Save view popover — small naming dialog anchored to the Save view button === function SaveViewPopover({ defaultName, onClose, onSave }) { const inputRef = React.useRef(null); const popRef = React.useRef(null); const [name, setName] = React.useState(defaultName || ""); React.useEffect(() => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } const onDoc = (e) => { if (popRef.current && !popRef.current.contains(e.target)) onClose(); }; const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [onClose]); const submit = () => { const trimmed = name.trim(); if (!trimmed) return; onSave(trimmed); }; return (
Save this view
Pin to your sidebar — current filters & sort are saved
setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(); }} placeholder="Untitled view" maxLength={48} />
); } function PipelineUnified({ defaultView = "board" }) { const viewer = useViewer(); const [view, setView] = useStateU(defaultView); const [activeJob, setActiveJob] = useStateU(null); // null = open roles, "__all__" = all, jobId, or saved-view id const [candidates, setCandidates] = useStateU(() => 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 [filters, setFilters] = useStateU([]); // shared by Board + Table + Calendar const [quickFilters, setQuickFilters] = useStateU({}); // boolean toggles: stalled / referrals / highrating const toggleQuickFilter = (id) => setQuickFilters(prev => { const n = { ...prev }; if (n[id]) delete n[id]; else n[id] = true; return n; }); const [sort, setSort] = useStateU({ keys: [] }); // shared multi-level sort: { keys: [{key,dir},...] } // Click on a column header: if it's the only/top sort, cycle asc → desc → remove. Otherwise promote it to top with asc. const handleSort = (key) => { setSort(prev => { const keys = prev.keys || []; const idx = keys.findIndex(s => s.key === key); // Top-priority single behavior preserved when this key is alone or already at top. if (idx === 0 && keys.length >= 1) { const cur = keys[0]; if (cur.dir === "asc") return { keys: [{ key, dir: "desc" }, ...keys.slice(1)] }; // desc → remove this key return { keys: keys.slice(1) }; } // Already in list but not top: promote to top, keep its dir if (idx > 0) { const moved = keys[idx]; return { keys: [moved, ...keys.filter((_, i) => i !== idx)] }; } // Not in list yet: append at lowest priority (so existing multi-sort isn't disrupted) return { keys: [...keys, { key, dir: "asc" }] }; }); }; const setSortKeys = (next) => setSort({ keys: next }); const [draggingId, setDraggingId] = useStateU(null); const [hoverStageId, setHoverStageId] = useStateU(null); const [toast, setToast] = useStateU(null); const [, showToast, ToastHost] = useAtsToast(); const [openCandidateId, setOpenCandidateId] = useStateU(null); const [reminderTarget, setReminderTarget] = useStateU(null); // candidate const [shareOpen, setShareOpen] = useStateU(false); const [saveOpen, setSaveOpen] = useStateU(false); const [savedViews, setSavedViews] = useStateU([]); // [{id, name, view, jobId}] const [renamingId, setRenamingId] = useStateU(null); const [addModalOpen, setAddModalOpen] = useStateU(false); const openCandidate = (id) => { setOpenCandidateId(id); window.location.hash = "candidate/" + id; }; // Deep link: listen for ats-open-candidate events (from hash routing) React.useEffect(() => { function handleDeepLink(e) { setOpenCandidateId(e.detail); } // Check if there's a pending deep link from initial page load if (window.__atsOpenCandidate) { setOpenCandidateId(window.__atsOpenCandidate); window.__atsOpenCandidate = null; } window.addEventListener('ats-open-candidate', handleDeepLink); return () => window.removeEventListener('ats-open-candidate', handleDeepLink); }, []); const openReminder = (id) => { const c = candidates.find(x => x.id === id); if (c) setReminderTarget(c); }; const SAVED_VIEW_TO_VIEW = { awaiting: "board", onsite: "calendar" }; const handleSidebarSelect = (id) => { // User-saved views: restore the saved view+jobId const saved = savedViews.find(v => v.id === id); if (saved) { setActiveJob(saved.jobId); setView(saved.view); showToast(`Opened saved view “${saved.name}”`); return; } setActiveJob(id); if (SAVED_VIEW_TO_VIEW[id]) { setView(SAVED_VIEW_TO_VIEW[id]); const labelMap = { awaiting: "Awaiting my review", onsite: "Onsite this week" }; showToast(`Showing: ${labelMap[id]}`); } else if (id === "__all__") { showToast("Showing all roles (open + closed)"); } else if (id === null) { showToast("Showing all open roles"); } else { const job = JOBS.find(j => j.id === id); if (job) showToast(`Filtered to ${job.title}`); } }; // Apply viewer access first, then sidebar filter, then view-level filter, then user column filters const preFilter = useMemoU(() => { let rows = visibleCandidates(viewer, candidates); if (activeJob === "__all__") { // no filter — include all } else if (activeJob === null) { const openIds = new Set(JOBS.filter(j => j.status === "open" || j.status === "active").map(j => j.id)); rows = rows.filter(c => openIds.has(c.jobId)); } else if (activeJob === "awaiting") { rows = rows.filter(c => ["intro","tech1","tech2","tech3"].includes(c.stage)); } else if (activeJob === "onsite") { rows = rows.filter(c => c.stage === "onsite"); } else if (activeJob && !SAVED_VIEW_TO_VIEW[activeJob]) { rows = rows.filter(c => c.jobId === activeJob); } // Quick-filter chips (independent of view) — AND together for (const qf of QUICK_FILTERS) { if (quickFilters[qf.id]) rows = rows.filter(qf.predicate); } return rows; }, [viewer, view, candidates, activeJob, quickFilters]); const visible = useMemoU( () => { const filtered = filters.length ? preFilter.filter(c => filters.every(f => applyFilter(f, c))) : preFilter; const arr = [...filtered]; const sortKeys = (sort.keys && sort.keys.length) ? sort.keys : [{ key: "stage", dir: "asc" }]; // Build chained comparator: lowest priority first (stable sort, so we sort by least significant first). // Easier: single comparator that walks the keys array. arr.sort((a, b) => { for (const { key, dir } of sortKeys) { const getter = SHEET_SORT_KEYS[key]; if (!getter) continue; const x = getter(a), y = getter(b); if (x === y) continue; const r = x < y ? -1 : 1; return dir === "desc" ? -r : r; } return 0; }); return arr; }, [preFilter, filters, sort] ); // Dynamic page title based on selection const titleSuffix = (() => { if (activeJob === "__all__") return "all roles"; if (activeJob === null) return "all open roles"; if (activeJob === "awaiting") return "awaiting my review"; if (activeJob === "onsite") return "onsite this week"; const job = JOBS.find(j => j.id === activeJob); return job ? job.title : "all open roles"; })(); const handleDrop = (id, stageId, ownerOverride = null) => { const moved = candidates.find(c => c.id === id); // Stage anchor: non-admins can't move candidates out of offer/hired/declined. // Note: backend's `declined_offer` is remapped to `declined` on the frontend // via STAGE_MAP in api-data.jsx. if (moved && viewer.role !== "admin" && moved.stage !== stageId && (moved.stage === "offer" || moved.stage === "hired" || moved.stage === "declined")) { setDraggingId(null); setHoverStageId(null); showToast(`${STAGE_BY_ID[moved.stage].label} stage is locked — ask an admin to change it.`, { tone: "warn" }); return; } setCandidates(prev => prev.map(c => { if (c.id !== id || (c.stage === stageId && !ownerOverride)) return c; const update = { ...c, stage: stageId, days: 0, lastActivity: `Moved to ${STAGE_BY_ID[stageId].label} just now`, }; if (ownerOverride) { update.recruiterName = ownerOverride.name; update.recruiterInitials = ownerOverride.initials; update.recruiterColor = ownerOverride.color; update.recruiterRole = ownerOverride.role; update.recruiterAvatar = ownerOverride.avatar_url || null; } return update; })); if (moved && moved.stage !== stageId) { setToast({ name: moved.name, from: STAGE_BY_ID[moved.stage].label, to: STAGE_BY_ID[stageId].label, id, fromStageId: moved.stage, }); setTimeout(() => setToast(null), 3500); updateCandidateStage(id, stageId); } if (ownerOverride && ownerOverride.id) { updateCandidateProfile(id, { assigned_recruiter_id: ownerOverride.id }); } setDraggingId(null); setHoverStageId(null); }; const handleAdvance = (id, owner) => { const c = candidates.find(x => x.id === id); if (!c) return; const next = nextStage(c.stage); if (!next) return; handleDrop(id, next, owner); }; const handleReject = (id) => { const c = candidates.find(x => x.id === id); if (!c) return; setCandidates(prev => prev.map(x => x.id === id ? { ...x, stage: "rejected", days: 0, lastActivity: "Rejected just now" } : x)); setToast({ name: c.name, from: STAGE_BY_ID[c.stage].label, to: "Rejected by us", id, fromStageId: c.stage, }); setTimeout(() => setToast(null), 3500); updateCandidateStage(id, "rejected"); }; const handleSetStage = (id, stageId, owner) => handleDrop(id, stageId, owner); // Revert the most recent stage move advertised in the toast. Bypasses the // anchor check so an interviewer who just moved a candidate INTO Offer can // still take it back. Once the stage lands in Offer/Hired/Declined, only // admins can move it again, but the immediate undo path stays open. const handleUndoToast = () => { if (!toast || !toast.id || !toast.fromStageId) return; const { id, fromStageId } = toast; setCandidates(prev => prev.map(c => c.id === id ? { ...c, stage: fromStageId, days: 0, lastActivity: `Reverted to ${STAGE_BY_ID[fromStageId].label} just now` } : c)); updateCandidateStage(id, fromStageId); setToast(null); }; // Listen for page changes to update active tab const [currentPage, setCurrentPage] = React.useState( typeof window.__atsCurrentPage === 'string' ? window.__atsCurrentPage : 'pipeline' ); React.useEffect(() => { const handlePageChange = (e) => setCurrentPage(e.detail); window.addEventListener('ats-page-change', handlePageChange); return () => window.removeEventListener('ats-page-change', handlePageChange); }, []); const activeTabMap = { 'pipeline': 'Pipeline', 'my-candidates': 'My candidates', 'settings': 'Permissions' }; // Compute modal content separately to ensure stable rendering const currentCandidate = openCandidateId ? candidates.find(x => x.id === openCandidateId) : null; return (
{ setView(id); showToast(`Switched to ${PIPELINE_VIEWS_LABEL[id] || id}`); }} savedViews={savedViews} activeSavedViewId={savedViews.find(v => v.jobId === activeJob && v.view === view)?.id || null} onSavedViewRename={(id, name) => { setSavedViews(prev => prev.map(v => v.id === id ? { ...v, name } : v)); showToast(`Renamed to “${name}”`, { tone: "success" }); }} onSavedViewDelete={(id) => { const removed = savedViews.find(v => v.id === id); setSavedViews(prev => prev.filter(v => v.id !== id)); if (removed) showToast(`Removed “${removed.name}”`); }} />

Pipeline — {titleSuffix}

{visible.length} candidates {activeJob && activeJob !== "__all__" ? "in this view" : `across ${JOBS.length} roles`} · Last synced from LinkedIn 4 minutes ago · Switch views below — same data, different lens.
{shareOpen && ( setShareOpen(false)} onCopied={() => { setShareOpen(false); showToast("Share link copied to clipboard", { tone: "success" }); }} /> )} {saveOpen && ( { const base = activeJob === "__all__" ? "All roles" : activeJob && activeJob !== "__all__" ? (JOBS.find(j => j.id === activeJob)?.title || "View") : "Open roles"; const viewLabel = PIPELINE_VIEWS_LABEL[view] || ""; return `${base} · ${viewLabel.replace(" view", "")}`; })()} onClose={() => setSaveOpen(false)} onSave={(name) => { const id = `saved-${Date.now()}`; setSavedViews(prev => [...prev, { id, name, view, jobId: activeJob }]); setSaveOpen(false); showToast(`Saved view “${name}” to your sidebar`, { tone: "success" }); }} /> )}
{(view === "board" || view === "table" || view === "calendar") && ( )} {view === "board" && ( { setDraggingId(null); setHoverStageId(null); }} onDrop={handleDrop} onDragOver={setHoverStageId} draggingId={draggingId} hoverStageId={hoverStageId} onOpen={openCandidate} onRemind={openReminder} onAdd={() => setAddModalOpen(true)} /> )} {view === "table" && ( )} {view === "calendar" && } {ToastHost} {toast && (
Moved {toast.name} from {toast.from}{toast.to}
)}
{currentCandidate && ( { setOpenCandidateId(null); history.replaceState(null, "", window.location.pathname + window.location.search); }} onAdvance={(owner) => handleAdvance(openCandidateId, owner)} onReject={() => handleReject(openCandidateId)} onSetStage={(stageId, owner) => handleSetStage(openCandidateId, stageId, owner)} onDelete={() => { setCandidates(prev => prev.filter(x => x.id !== openCandidateId)); setOpenCandidateId(null); history.replaceState(null, "", window.location.pathname + window.location.search); }} siblings={candidates.filter(x => x.jobId === currentCandidate.jobId)} onNavigate={(dir) => { const jobCandidates = candidates.filter(x => x.jobId === currentCandidate.jobId); const idx = jobCandidates.findIndex(x => x.id === openCandidateId); if (idx === -1) return; const next = jobCandidates[(idx + dir + jobCandidates.length) % jobCandidates.length]; setOpenCandidateId(next.id); window.location.hash = "candidate/" + next.id; }} /> )} {reminderTarget && ( j.id === reminderTarget.jobId)} sender={viewer} onClose={() => setReminderTarget(null)} /> )} {addModalOpen && ( setAddModalOpen(false)} /> )}
); } Object.assign(window, { PipelineUnified, PipelineKanbanView, PipelineSheetView, PipelineCalendar, ViewTabs });