/* pipeline.jsx — Full Kanban pipeline page (P0 rewrite).
* Requires: app.jsx, email-store.jsx, email-composer.jsx
*/
/* ── Local icon helpers ──────────────────────────────────────────────────── */
const IcMail = (p) => ();
const IcPhone = (p) => ();
const IcMapPin = (p) => ();
const IcFileText = (p) => ();
const IcTrash = (p) => ();
const IcUser = (p) => ();
const IcMoreH = (p) => ();
const IcArrowRight = (p) => ();
const IcLinkedIn = (p) => ();
/* ── Stage definitions ───────────────────────────────────────────────────── */
const KN_STAGES = [
{ id: "intro_screening", label: "Intro screening", hint: "30-min recruiter call", accent: "var(--neutral-60)" },
{ id: "hiring_manager", label: "Hiring manager", hint: "First leadership interview", accent: "var(--indigo-50)" },
{ id: "technical_test", label: "Technical test", hint: "Take-home or paired exercise", accent: "var(--violet-50)" },
{ id: "offer", label: "Offer", hint: "Offer extended", accent: "var(--yellow-50)" },
{ id: "hired", label: "Hired", hint: "Signed and starting", accent: "var(--green-50)" },
{ id: "rejected", label: "Rejected", hint: "Closed out of pipeline", accent: "var(--red-50)" },
];
const KN_STAGE_MAP = {};
KN_STAGES.forEach(s => { KN_STAGE_MAP[s.id] = s; });
// Drawer-only stage map — includes "applied" so candidates triaged from the
// Candidates page can be moved into the pipeline. Not shown as a Kanban column.
KN_STAGE_MAP["applied"] = { id: "applied", label: "Applied", hint: "Awaiting screening", accent: "var(--neutral-40)" };
const CANONICAL_TO_STAGE_ID = {
"Applied": "applied",
"Screening": "intro_screening",
"Interview": "hiring_manager",
"Take-home": "technical_test",
"Offer": "offer",
"Hired": "hired",
"Rejected": "rejected",
};
const STAGE_BG = {
intro_screening: "var(--neutral-20)",
hiring_manager: "var(--indigo-10)",
technical_test: "var(--violet-10)",
offer: "var(--yellow-10)",
hired: "var(--green-10)",
rejected: "var(--red-10)",
};
const scoreClass = (s) => s >= 75 ? "high" : s >= 50 ? "mid" : "low";
/* ── Departments list (for new posting modal) ───────────────────────────── */
const DEPARTMENTS_LIST = [
{ id: "dept_eng", name: "Engineering" },
{ id: "dept_design", name: "Design" },
{ id: "dept_product", name: "Product" },
{ id: "dept_ops", name: "Operations & Compliance" },
{ id: "dept_growth", name: "Growth & Marketing" },
{ id: "dept_partner", name: "Partnerships" },
];
/* ── KnMoveMenu — kebab popover ─────────────────────────────────────────── */
function KnMoveMenu({ opp, onMove, onReject, onClose }) {
const { useEffect, useRef } = React;
const ref = useRef(null);
useEffect(() => {
const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
document.addEventListener("mousedown", fn);
return () => document.removeEventListener("mousedown", fn);
}, [onClose]);
const visibleStages = KN_STAGES.filter(s => s.id !== opp.stageId && s.id !== "rejected");
return (
Move to
{visibleStages.map(s => (
))}
);
}
/* ── KnCard ──────────────────────────────────────────────────────────────── */
function KnCard({ opp, isDragging, onDragStart, onDragEnd, onClick, onMove, onReject }) {
const { useState, useRef } = React;
const [menuOpen, setMenuOpen] = useState(false);
const tone = scoreClass(opp.score || 0);
const scoreColor = tone === "high" ? "var(--green-70)" : tone === "mid" ? "var(--indigo-50)" : "var(--neutral-60)";
return (
{/* header row */}
{opp.name}
{opp.roleLabel || opp.role || ""}
{opp.score}
{/* time + source */}
{opp.lastTouch || "—"}
{opp.loc && (
{opp.loc}
)}
{/* reason tags */}
{opp.reasons && opp.reasons.length > 0 && (
{opp.reasons.slice(0, 2).map((r, i) => (
{r}
))}
)}
{/* kebab menu */}
{menuOpen && (
e.stopPropagation()}>
setMenuOpen(false)}
/>
)}
);
}
/* ── KnColumn ────────────────────────────────────────────────────────────── */
function KnColumn({ stage, opps, dragOver, draggingId, onDragOverCol, onDragLeaveCol, onDropCol, onDragStartCard, onDragEndCard, onCardClick, onMove, onReject }) {
const count = opps.length;
const isDragTarget = dragOver === stage.id;
const stageAccent = stage.accent;
return (
{ e.preventDefault(); onDragOverCol(stage.id); }}
onDragLeave={() => onDragLeaveCol(stage.id)}
onDrop={(e) => { e.preventDefault(); onDropCol(stage.id); }}
>
{/* column header */}
{stage.label}
{count}
{stage.hint}
{/* drop zone */}
{opps.map(opp => (
{ e.stopPropagation(); onDragStartCard(e, opp); }}
onDragEnd={onDragEndCard}
onClick={() => onCardClick(opp)}
onMove={onMove}
onReject={onReject}
/>
))}
{count === 0 && (
{isDragTarget ? "Drop here to move" : "No candidates"}
)}
);
}
/* ── PipelineDrawer ──────────────────────────────────────────────────────── */
/* Rating helpers — shared between the feedback list and the Add form */
const RATINGS = [
{ id: "strong_yes", label: "Strong yes", tone: "var(--green-70)", bg: "var(--green-20)" },
{ id: "yes", label: "Yes", tone: "var(--green-60)", bg: "var(--green-10)" },
{ id: "mixed", label: "Mixed", tone: "var(--yellow-70)", bg: "var(--yellow-10)" },
{ id: "no", label: "No", tone: "var(--red-60)", bg: "var(--red-10)" },
{ id: "strong_no", label: "Strong no", tone: "var(--red-70)", bg: "var(--red-20)" },
];
const ratingMeta = (id) => RATINGS.find(r => r.id === id) || RATINGS[2];
function AddFeedbackForm({ onCancel, onSubmit }) {
const [rating, setRating] = React.useState("yes");
const [notes, setNotes] = React.useState("");
const [strengthsRaw, setStrengthsRaw] = React.useState("");
const [concernsRaw, setConcernsRaw] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const handleSubmit = () => {
if (!notes.trim() || submitting) return;
setSubmitting(true);
const strengths = strengthsRaw.split("\n").map(s => s.trim()).filter(Boolean);
const concerns = concernsRaw .split("\n").map(s => s.trim()).filter(Boolean);
onSubmit({ rating, notes: notes.trim(), strengths, concerns })
.catch(err => { console.error("[ATS] add feedback failed", err); alert("Feedback failed to save — see console."); })
.finally(() => setSubmitting(false));
};
return (
{RATINGS.map(r => (
))}
);
}
function PipelineDrawer({ oppId, onClose, onMove, onReject }) {
const { useState, useEffect } = React;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [adding, setAdding] = useState(false);
const [aiSummary, setAiSummary] = useState(null);
const [summarizing, setSummarizing] = useState(false);
const refreshFeedback = React.useCallback(() => {
if (!oppId) return;
fetch(`/api/opportunities/${oppId}/feedback`)
.then(r => r.ok ? r.json() : [])
.then(list => setData(prev => prev ? { ...prev, feedback: list } : prev))
.catch(() => {});
}, [oppId]);
const refreshAiSummary = React.useCallback(() => {
if (!oppId) return;
setSummarizing(true);
fetch(`/api/opportunities/${oppId}/feedback-summary`)
.then(r => r.ok ? r.json() : null)
.then(s => setAiSummary(s))
.catch(() => setAiSummary(null))
.finally(() => setSummarizing(false));
}, [oppId]);
useEffect(() => {
if (!oppId) return;
setLoading(true);
setData(null);
setAiSummary(null);
fetch(`/api/candidates/${oppId}`)
.then(r => {
if (r.status === 401) {
// Session lost — redirect to login, preserving the current URL (incl. hash).
const target = window.location.pathname + window.location.search + window.location.hash;
window.location.href = `/auth/login?next=${encodeURIComponent(target || '/')}`;
return null;
}
return r.ok ? r.json() : null;
})
.then(d => {
if (!d) return;
setData(d);
// Auto-trigger criteria analysis if score is missing and we have a resume.
const needsAnalysis = d.resumeKey && (!d.criteria || d.criteria.length === 0 || d._score == null);
if (needsAnalysis) {
setAnalyzing(true);
fetch(`/api/candidates/${oppId}/analyze`, { method: "POST" })
.then(r => r.ok ? r.json() : null)
.then(a => {
if (a) setData(prev => prev ? {
...prev,
aiAnalysis: a,
criteria: a.criteria || prev.criteria,
criteriaScores: a.scores || prev.criteriaScores,
_score: a.score ?? prev._score,
} : prev);
})
.catch(err => console.error("[ATS] analyze failed", err))
.finally(() => setAnalyzing(false));
}
// If there's feedback, fetch AI summary in parallel.
if (d.feedback && d.feedback.length > 0) refreshAiSummary();
})
.catch(() => {})
.finally(() => setLoading(false));
}, [oppId, refreshAiSummary]);
const handleRerunAnalysis = () => {
if (!oppId || analyzing) return;
setAnalyzing(true);
fetch(`/api/candidates/${oppId}/analyze?force=1`, { method: "POST" })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(a => setData(prev => prev ? {
...prev,
aiAnalysis: a, criteria: a.criteria, criteriaScores: a.scores, _score: a.score ?? prev._score,
} : prev))
.catch(err => { console.error("[ATS] analyze failed", err); alert("Analysis failed — see console."); })
.finally(() => setAnalyzing(false));
};
const setCriterion = (criterionId, state) => {
if (!oppId) return;
// Optimistic update
setData(prev => {
if (!prev) return prev;
const next = { ...(prev.criteriaScores || {}) };
next[criterionId] = { ...(next[criterionId] || {}), state, source: "human" };
return { ...prev, criteriaScores: next };
});
fetch(`/api/opportunities/${oppId}/criteria-scores`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ criterionId, state }),
})
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(res => setData(prev => prev ? { ...prev, criteriaScores: res.scores, _score: res.score ?? prev._score } : prev))
.catch(err => { console.error("[ATS] criteria save failed", err); alert("Criterion failed to save — see console."); });
};
const handleAddFeedback = (entry) => {
return fetch(`/api/opportunities/${oppId}/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry),
})
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(() => {
setAdding(false);
refreshFeedback();
refreshAiSummary();
});
};
/* Determine next stage for "move to next" */
const currentStageId = data?.stageId;
const stageOrder = ["applied", "intro_screening", "hiring_manager", "technical_test", "offer", "hired"];
const currentIdx = stageOrder.indexOf(currentStageId);
const nextStageId = currentIdx >= 0 && currentIdx < stageOrder.length - 1
? stageOrder[currentIdx + 1]
: null;
const nextStage = nextStageId ? KN_STAGE_MAP[nextStageId] : null;
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{/* header */}
{data ? (
<>
{data.name}
{data.postingTitle || "—"}
>
) : (
{loading ? "Loading…" : "Candidate"}
)}
Copied
{/* body */}
{loading && (
Loading candidate details…
)}
{!loading && !data && (
Could not load candidate data.
)}
{data && (
<>
{/* score */}
Airtm Rank
{(data._score || 0) >= 75 ? "Strong fit" : (data._score || 0) >= 50 ? "Good fit" : "Possible fit"}
{data._reasons && data._reasons.length > 0 && (
{data._reasons.map((r, i) => (
{r}
))}
)}
{/* section: Match score (criteria editor) */}
{(analyzing || (data.criteria && data.criteria.length > 0)) && (
Match score
{data.criteria && data.criteria.length > 0 && !analyzing && (
)}
{analyzing && (!data.criteria || data.criteria.length === 0) && (
Reading resume and scoring against the posting…
)}
{data.aiAnalysis && data.aiAnalysis.summary && (
{data.aiAnalysis.summary}
)}
{data.criteria && data.criteria.length > 0 && (
{data.criteria.map(c => {
const s = (data.criteriaScores || {})[c.id] || {};
return (
{c.label}
Weight · {c.weight}
{s.source === "human" && · edited}
{["met", "partial", "missing"].map(v => {
const active = s.state === v;
const colors = v === "met"
? { fg: "var(--green-70)", bg: "var(--green-10)", brd: "var(--green-30)" }
: v === "partial"
? { fg: "var(--yellow-70)", bg: "var(--yellow-10)", brd: "var(--yellow-30)" }
: { fg: "var(--content-tertiary)", bg: "var(--bg-tertiary)", brd: "var(--stroke-secondary)" };
return (
);
})}
{s.evidence && (
"{s.evidence}"
)}
);
})}
)}
)}
{/* section: contact */}
{/* section: application */}
Application
Source
{data.source || "—"}
Applied
{data.createdAt ? new Date(data.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—"}
Stage
{data.stageLabel || "—"}
Last interaction
{data.lastInteractionAt ? new Date(data.lastInteractionAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
{data.resumeKey && (
View resume
↗
)}
{/* section: feedback / comments */}
Feedback
{!adding && (
)}
{adding && (
setAdding(false)}
onSubmit={handleAddFeedback}
/>
)}
{(summarizing || aiSummary) && (
AI summary
{aiSummary && (
{ratingMeta(aiSummary.tone).label}
)}
{summarizing && !aiSummary && (
Synthesizing feedback…
)}
{aiSummary && (
<>
{aiSummary.headline}
{aiSummary.strengths && aiSummary.strengths.length > 0 && (
Strengths
{aiSummary.strengths.map((s, i) => - {s}
)}
)}
{aiSummary.concerns && aiSummary.concerns.length > 0 && (
Concerns
{aiSummary.concerns.map((c, i) => - {c}
)}
)}
{aiSummary.nextStep && (
Next: {aiSummary.nextStep}
)}
>
)}
)}
{data.feedback && data.feedback.length > 0 ? (
{data.feedback.map(fb => {
const rm = ratingMeta(fb.rating);
return (
{fb.interviewerName || fb.interviewerEmail}
{fb.stageId} · {fb.submittedAt ? new Date(fb.submittedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—"}
{rm.label}
{fb.notes && (
{fb.notes}
)}
{fb.strengths && fb.strengths.length > 0 && (
Strengths
{fb.strengths.map((s, i) => - {s}
)}
)}
{fb.concerns && fb.concerns.length > 0 && (
Concerns
{fb.concerns.map((c, i) => - {c}
)}
)}
);
})}
) : !adding && (
No feedback yet. Add the first review to start building the picture.
)}
{/* Legacy recruiter notes — only shown if present */}
{data.notes && (
Recruiter notes
{data.notes}
)}
>
)}
{/* footer actions */}
{data && (
{nextStage && (
)}
)}
);
}
/* ── NewPostingModal ─────────────────────────────────────────────────────── */
function NewPostingModal({ onClose, onCreated }) {
const { useState } = React;
const [form, setForm] = useState({
title: "", department: "dept_eng", location: "", type: "Full-time",
team: "", salaryRange: "", overview: "",
});
const [responsibilities, setResponsibilities] = useState([""]);
const [requirements, setRequirements] = useState([""]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const setField = (k, v) => setForm(f => ({ ...f, [k]: v }));
const addItem = (setter) => setter(a => [...a, ""]);
const setItem = (setter, i, v) => setter(a => { const n = [...a]; n[i] = v; return n; });
const removeItem = (setter, i) => setter(a => a.filter((_, idx) => idx !== i));
const handleSubmit = (e) => {
e.preventDefault();
if (!form.title.trim()) { setError("Title is required."); return; }
setSaving(true);
setError(null);
const body = {
title: form.title.trim(),
department_id: form.department,
location: form.location.trim(),
type: form.type,
team: form.team.trim(),
salary_range: form.salaryRange.trim(),
overview: form.overview.trim(),
responsibilities: responsibilities.filter(r => r.trim()),
requirements: requirements.filter(r => r.trim()),
};
fetch("/api/postings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((created) => {
setSaving(false);
onCreated && onCreated(created);
onClose();
})
.catch((err) => {
setSaving(false);
setError(`Failed to create posting (${err}). Check server logs.`);
});
};
return (
{ if (e.target === e.currentTarget) onClose(); }}>
New posting
);
}
/* ── PipelinePage ────────────────────────────────────────────────────────── */
function PipelinePage({ onNav }) {
const { useState, useEffect, useCallback, useMemo } = React;
/* ── State ── */
const [opps, setOpps] = useState([]);
const [searchQ, setSearchQ] = useState("");
const [postingFilter,setPostingFilter] = useState("all");
const [groupBy, setGroupBy] = useState("stage"); // "stage" | "posting"
const [draggingId, setDraggingId] = useState(null);
const [draggingOpp, setDraggingOpp] = useState(null);
const [dragOverCol, setDragOverCol] = useState(null);
const [drawerOppId, setDrawerOppId] = useState(null);
const [composer, setComposer] = useState(null); // { opp, toStageId }
const [newPostingOpen, setNewPostingOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
/* ── Load data ── */
useEffect(() => {
fetch("/api/dashboard")
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.candidates?.length) {
setOpps(
data.candidates
.map(c => ({
...c,
stageId: CANONICAL_TO_STAGE_ID[c.stage] || "intro_screening",
}))
.filter(c => c.stageId !== "applied")
);
}
})
.catch(() => {
const cands = window.CANDIDATES || [];
setOpps(
cands
.map(c => ({
...c,
stageId: CANONICAL_TO_STAGE_ID[c.stage] || "intro_screening",
}))
.filter(c => c.stageId !== "applied")
);
});
}, []);
/* ── Keyboard shortcuts ── */
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);
}
};
window.addEventListener("keydown", fn);
return () => window.removeEventListener("keydown", fn);
}, []);
/* ── Posting options for filter ── */
const postingOptions = useMemo(() => {
const map = {};
opps.forEach(o => {
if (o.role && o.roleLabel) map[o.role] = o.roleLabel;
});
return [
{ value: "all", label: "All postings" },
...Object.entries(map).map(([value, label]) => ({ value, label })),
];
}, [opps]);
/* ── Filter + search ── */
const filteredOpps = useMemo(() => {
let list = opps;
if (postingFilter !== "all") {
list = list.filter(o => o.role === postingFilter);
}
if (searchQ.trim()) {
const q = searchQ.toLowerCase();
list = list.filter(o =>
(o.name || "").toLowerCase().includes(q) ||
(o.roleLabel || "").toLowerCase().includes(q) ||
(o.loc || "").toLowerCase().includes(q)
);
}
return list;
}, [opps, postingFilter, searchQ]);
/* ── Stage commit (after email) ── */
const commitStageMove = useCallback((opp, toStageId) => {
/* Optimistic update */
setOpps(prev => prev.map(o => o.id === opp.id ? { ...o, stageId: toStageId } : o));
/* API call */
fetch(`/api/opportunities/${opp.id}/stage`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stageId: toStageId }),
}).catch(() => {
/* rollback on error */
setOpps(prev => prev.map(o => o.id === opp.id ? { ...o, stageId: opp.stageId } : o));
});
}, []);
/* ── Trigger email composer before moving ── */
const initiateMove = useCallback((opp, toStageId) => {
setDrawerOppId(null);
setComposer({ opp, toStageId });
}, []);
const handleReject = useCallback((opp) => {
initiateMove(opp, "rejected");
}, [initiateMove]);
/* ── Composer callbacks ── */
const handleComposerSendAndMove = useCallback((emailData) => {
if (!composer) return;
const oppId = composer.opp.id;
fetch("/api/email/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: emailData.to,
subject: emailData.subject,
body: emailData.body,
opportunityId: oppId,
}),
})
.then(r => { if (!r.ok) console.error("[ATS] email send failed", r.status); })
.catch(err => console.error("[ATS] email send error", err));
commitStageMove(composer.opp, composer.toStageId);
setComposer(null);
}, [composer, commitStageMove]);
const handleComposerDiscardAndMove = useCallback(() => {
if (!composer) return;
commitStageMove(composer.opp, composer.toStageId);
setComposer(null);
}, [composer, commitStageMove]);
const handleComposerCancel = useCallback(() => {
setComposer(null);
}, []);
/* ── Drag handlers ── */
const handleDragStart = useCallback((e, opp) => {
setDraggingId(opp.id);
setDraggingOpp(opp);
e.dataTransfer.effectAllowed = "move";
}, []);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDraggingOpp(null);
setDragOverCol(null);
}, []);
const handleDragOverCol = useCallback((stageId) => {
setDragOverCol(stageId);
}, []);
const handleDragLeaveCol = useCallback(() => {
/* we do NOT clear dragOverCol on leave to avoid flicker */
}, []);
const handleDropCol = useCallback((stageId) => {
setDragOverCol(null);
if (!draggingOpp) return;
if (draggingOpp.stageId === stageId) {
setDraggingId(null);
setDraggingOpp(null);
return;
}
const opp = draggingOpp;
setDraggingId(null);
setDraggingOpp(null);
initiateMove(opp, stageId);
}, [draggingOpp, initiateMove]);
/* ── Build composer stage info ── */
const composerFromStage = composer ? (KN_STAGE_MAP[composer.opp.stageId] || KN_STAGES[0]) : null;
const composerToStage = composer ? (KN_STAGE_MAP[composer.toStageId] || KN_STAGES[0]) : null;
/* ── Derived: opps by stage ── */
const oppsByStage = useMemo(() => {
const map = {};
KN_STAGES.forEach(s => { map[s.id] = []; });
filteredOpps.forEach(o => {
const sid = o.stageId || "intro_screening";
if (map[sid]) map[sid].push(o);
else map["intro_screening"].push(o);
});
return map;
}, [filteredOpps]);
/* ── Derived: posting groups for matrix view ── */
const postingGroups = useMemo(() => {
const map = {};
filteredOpps.forEach(o => {
const key = o.role || "unknown";
if (!map[key]) map[key] = { role: key, label: o.roleLabel || key, opps: [] };
map[key].opps.push(o);
});
return Object.values(map);
}, [filteredOpps]);
return (
setSearchOpen(true)}
onSettings={() => setSettingsOpen(true)}
/>
{/* page body */}
{/* page header */}
{" / "}
Pipeline
Pipeline
{filteredOpps.length} candidate{filteredOpps.length !== 1 ? "s" : ""} in active stages
{/* controls */}
{/* search bar */}
setSearchQ(e.target.value)}
placeholder="Search candidates…"
style={{ border: 0, outline: 0, fontSize: 13, background: "transparent",
color: "var(--content-primary)", fontFamily: "var(--font-sans)", width: 160 }}
/>
{/* posting filter */}
{/* group by toggle */}
{[{ id: "stage", label: "Stage" }, { id: "posting", label: "Posting" }].map(v => (
))}
{/* new posting button */}
{/* kanban or matrix */}
{groupBy === "stage" ? (
/* Stage kanban */
{KN_STAGES.map(stage => (
setDrawerOppId(opp.id)}
onMove={initiateMove}
onReject={handleReject}
/>
))}
) : (
/* Posting matrix */
Posting
{KN_STAGES.map(s => (
{s.label}
))}
{postingGroups.map(pg => (
{pg.label}
{KN_STAGES.map(s => {
const cellOpps = pg.opps.filter(o => o.stageId === s.id);
return (
{cellOpps.length === 0 ? (
—
) : (
)}
);
})}
))}
{postingGroups.length === 0 && (
No candidates match the current filters.
)}
)}
{/* ── Overlays ── */}
{/* Candidate drawer */}
{drawerOppId && (
setDrawerOppId(null)}
onMove={(opp, toStageId) => { setDrawerOppId(null); initiateMove(opp, toStageId); }}
onReject={(opp) => { setDrawerOppId(null); handleReject(opp); }}
/>
)}
{/* Email composer */}
{composer && composerFromStage && composerToStage && (
)}
{/* New posting modal */}
{newPostingOpen && (
setNewPostingOpen(false)}
onCreated={() => {}}
/>
)}
{/* AI search (reuse existing) */}
setSearchOpen(false)}
onOpenCandidate={(c) => { setSearchOpen(false); setDrawerOppId(c.id); }}
/>
setSettingsOpen(false)} />
);
}
window.PipelinePage = PipelinePage;
window.NewPostingModal = NewPostingModal;
window.PipelineDrawer = PipelineDrawer;