{isOwnerMode
? <>The owner DRIs this candidate end-to-end. They'll get nudges and notifications.>
: <>They'll be assigned as the candidate's owner for this stage.>
}
setQuery(e.target.value)}
placeholder="Search team — name or role"
/>
{filteredSuggested.length > 0 && (
<>
{isOwnerMode ? "Suggested" : `Suggested for ${stage.label.toLowerCase()}`}
One running document for this candidate. Notes post as you add them — the panel reads
them at their leisure. Pin the ones that matter for the hire decision.
This will permanently remove the candidate profile, all interview notes, and activity history. This cannot be undone.
>
)}
>
);
}
// === Reject dialog — compose email and send via Gmail ===
const REJECT_REASONS = [
{ id: "level", label: "Level mismatch", template: (name) => `Hi ${name},\n\nThank you again for taking the time to talk with us. After discussing internally, we don't think the level of this role is the right fit at this moment — we're looking for someone with more experience leading ambiguous multi-system projects end-to-end.\n\nWe were really impressed with your depth in {topic} and would love to stay in touch as we open roles that match. We'd also be happy to make intros to companies in our network where your background seems like a stronger match.\n\nWishing you the best,\nThe Nuance Labs Team` },
{ id: "skills", label: "Skills gap", template: (name) => `Hi ${name},\n\nThank you for the time and energy you put into our interview process. We ultimately decided not to move forward — the bar for {skill} in this role is very high, and other candidates in the pipeline came in with more direct experience in that specific area.\n\nThis is in no way a reflection on you as an engineer. We enjoyed our conversation and we'd welcome you to apply again in 6–12 months as you build more depth there, or for a different role on the team.\n\nWishing you the best,\nThe Nuance Labs Team` },
{ id: "culture", label: "Values / working style", template: (name) => `Hi ${name},\n\nThank you for spending time with our team. After discussing, we've decided not to move forward with your candidacy.\n\nThis call was a close one and the decision was about how we work together day-to-day rather than your abilities — we think you'll do excellent work and we appreciated every conversation.\n\nWishing you the best on what comes next,\nThe Nuance Labs Team` },
{ id: "comp", label: "Compensation mismatch", template: (name) => `Hi ${name},\n\nThanks again for the great conversations over the past few weeks. Unfortunately we weren't able to close the gap on compensation for this role, so we're going to pause things here.\n\nIf the framing ever shifts on your end, or if we open a role where the band works better, we'd love to reconnect. Genuinely appreciated your time and the thoughtfulness you brought to every conversation.\n\nBest,\nThe Nuance Labs Team` },
{ id: "other", label: "Other / custom", template: (name) => `Hi ${name},\n\nThank you for taking the time to interview with Nuance. After careful consideration, we've decided to move forward with other candidates whose background more closely matches what we need right now.\n\nThis isn't a reflection on the quality of your work — we were genuinely impressed. We'd love to stay in touch and reconnect as future roles open up.\n\nWishing you the best,\nThe Nuance Labs Team` },
];
const REJECT_SUBJECT = (name, jobTitle) => `Update on your job application at Nuance Labs`;
function RejectDialog({ c, job, onClose, onReject }) {
const tweaks = (typeof window !== 'undefined' && window.__atsTweaks) || { defaultCc: '', showCcField: true };
const firstName = c.name.split(" ")[0];
const [reasonId, setReasonId] = useStateC("level");
const [cc, setCc] = useStateC(tweaks.defaultCc || "");
const [subject, setSubject] = useStateC(REJECT_SUBJECT(firstName, job?.title || "the role"));
const defaultBody = REJECT_REASONS[0].template(firstName);
const [body, setBody] = useStateC(defaultBody);
const [touchedBody, setTouchedBody] = useStateC(false);
useEffectC(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const pickReason = (id) => {
setReasonId(id);
if (!touchedBody) {
const r = REJECT_REASONS.find(x => x.id === id);
setBody(r.template(firstName));
}
};
const sendViaGmail = () => {
const to = encodeURIComponent(c.email || `${firstName.toLowerCase()}@example.com`);
const su = encodeURIComponent(subject);
const bd = encodeURIComponent(body);
const ccPart = cc.trim() ? `&cc=${encodeURIComponent(cc.trim())}` : "";
const url = `https://mail.google.com/mail/?view=cm&fs=1&to=${to}${ccPart}&su=${su}&body=${bd}`;
window.open(url, "_blank", "noopener");
onReject && onReject(reasonId);
onClose();
};
const rejectSilently = () => {
onReject && onReject(reasonId);
onClose();
};
return (
<>
e.stopPropagation()} style={{ zIndex: 10000 }}>
Reject {c.name}
They'll be moved to Rejected. You can send a note or skip it.
{
if (id === c.jobId) return;
const sel = JOBS.find(j => j.id === id);
if (sel) showToast(`${sel.title} selected — open its artboard from the canvas`);
else if (id === "__all__") showToast("All roles selected");
else if (id === null) showToast("Open roles selected");
else showToast("Saved view selected");
}} />
{ToastHost}
);
}
// Modal / pop-up from Kanban — same body, framed identically
// When `embedded` is true (used as a standalone artboard), it picks a default
// candidate, fills the artboard, and skips the scrim/click-outside behavior.
function CandidateModal({ candidate, onClose, embedded = false, onNavigate, onAdvance, onReject, onSetStage, onDelete }) {
// Use ref to ensure portal container is stable across renders (fixes React 18 portal cleanup issue)
const portalRootRef = useRefC(null);
if (!portalRootRef.current) {
portalRootRef.current = document.getElementById('portal-root') || document.body;
}
useEffectC(() => {
if (embedded || !candidate) return;
document.body.style.overflow = "hidden";
const onKey = (e) => {
const tag = e.target.tagName;
const isTyping = tag === "INPUT" || tag === "TEXTAREA" || e.target.isContentEditable;
if (e.key === "Escape" && !isTyping) onClose && onClose();
else if (!isTyping && (e.key === "ArrowDown" || e.key === "j")) { onNavigate && onNavigate(1); }
else if (!isTyping && (e.key === "ArrowUp" || e.key === "k")) { onNavigate && onNavigate(-1); }
};
window.addEventListener("keydown", onKey);
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
}, [onClose, embedded, onNavigate, candidate]);
const c = candidate || CANDIDATES.find(x => x.stage === "onsite") || CANDIDATES[0];
if (!c) return null;
const job = JOBS.find(j => j.id === c.jobId);
const inner = (