// 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 (
);
}
if (field.kind === "range") {
const [lo, hi] = filter.value || [field.min, field.max];
return (
);
}
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 (
setOpen(o => !o)} disabled={!available.length}>
+ Filter
{open && available.length > 0 && (
{available.map(f => (
{ onAdd(f); setOpen(false); }}
>{f.label}
))}
)}
);
}
// 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 (
{QUICK_FILTERS.map(qf => {
const active = !!(quickFilters && quickFilters[qf.id]);
return (
toggleQuickFilter && toggleQuickFilter(qf.id)}
title={qf.desc}
>
{qf.icon}
{qf.label}
{active && × }
);
})}
{filters.map((f, i) => (
updateFilter(i, next)}
onRemove={() => removeFilter(i)}
/>
))}
{sortKeys.map((s, i) => (
toggleSortDir(i)}
onRemove={() => removeSort(i)}
onMoveUp={i > 0 ? () => moveSort(i, -1) : null}
onMoveDown={i < sortKeys.length - 1 ? () => moveSort(i, 1) : null}
/>
))}
{hasActivity && (
{visibleCount} of {total}
)}
{filters.length > 0 && (
Clear filters
)}
{sortKeys.length > 0 && (
Clear sort
)}
);
}
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 (
setOpen(o => !o)} disabled={!available.length}>
+ Sort
{open && available.length > 0 && (
{available.map(f => (
{ onAdd(f.id); setOpen(false); }}
>{f.label}
))}
)}
);
}
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:
{label} {sort.dir === "asc" ? "↑" : "↓"}
{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 => (
onSelect(v.id)}
title={v.desc}
>
{v.icon}
{v.label}
{v.starred && ★ }
))}
onAction && onAction("Filter panel — coming soon")}>Filter
onAction && onAction("Sort panel — coming soon")}>Sort
onAction && onAction("Group panel — coming soon")}>Group
onAction && onAction("More options — coming soon")}>⋯
);
}
// === 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 (
setCalMonthIdx(i => Math.max(0, i - 1))}>‹
{months[calMonthIdx]}
setCalMonthIdx(i => Math.min(months.length - 1, i + 1))}>›
{ setCalMonthIdx(3); onAction && onAction("Jumped to today"); }}>Today
Stage: interviewing ×
{["Day","Week","Month"].map(z => (
setCalZoom(z)}>{z}
))}
);
}
const filters = [
{ id: "stage", label: "Stage" },
{ id: "recruiter", label: "Recruiter" },
{ id: "source", label: "Source" },
];
return (
onAction && onAction("Showing all open roles")}>
All open roles▾
{filters.map(f => (
toggleFilter(f.id, f.label)}>
{f.label} ▾
))}
{viewId === "stalled" && (
Days in stage > 7 ×
)}
{viewId === "referrals" && (
Source = referral ×
)}
{Object.entries(active).map(([id, label]) => (
{label}: any
toggleFilter(id, label)} style={{ cursor: "pointer" }}>×
))}
onAction && onAction("Grouped by Stage")}>
Group: Stage ▾
onAction && onAction("Sorted by Days in stage")}>
Sort: Days in stage ▾
);
}
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 === "copied" ? "✓ Copied" : "Copy link"}
{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}
/>
Save
);
}
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.
{ setShareOpen(v => !v); setSaveOpen(false); }}
>Share view
{shareOpen && (
setShareOpen(false)}
onCopied={() => { setShareOpen(false); showToast("Share link copied to clipboard", { tone: "success" }); }}
/>
)}
{ setSaveOpen(v => !v); setShareOpen(false); }}
>Save view
{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" });
}}
/>
)}
setAddModalOpen(true)}>+ Add candidate
{(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}
Undo
)}
{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 });