"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { BarChart3, Briefcase, CalendarDays, Columns3, Menu, Sparkles, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnalyticsView } from "./analytics-view";
import { Avatar, IconButton, mergeTemplate, Overlay, Toasts, useToasts } from "./ats-ui";
import { BulkReject } from "./bulk-reject";
import { CandidateDrawer } from "./candidate-drawer";
import { SEED_ATS, SEED_TODAY, uid } from "./data";
import { InterviewsView } from "./interviews-view";
import { JobsView } from "./jobs-view";
import { PipelineView } from "./pipeline-view";
import { Scheduler } from "./scheduler";
import type { AtsData, AtsView, Candidate, EmailLog, Interview, Scorecard, StageId } from "./types";
export type { AtsData, Candidate, EmailTemplate, Interview, Job, Scorecard, StageId } from "./types";
export type RecruitingAtsAppProps = {
/** Jobs, candidates, interviewers, templates and historical hires. Defaults to a seeded workspace. */
initialData?: AtsData;
/** Signed-in recruiter (must exist in `interviewers`). */
currentUserId?: string;
/** "Today" as YYYY-MM-DD. */
today?: string;
defaultView?: AtsView;
/** Called with the full dataset after every change — persist it here. */
onChange?: (data: AtsData) => void;
onCandidateStageChange?: (candidate: Candidate, from: StageId, to: StageId) => void;
onRejectCandidates?: (ids: string[], templateId: string, emails: { candidateId: string; subject: string; body: string }[]) => void;
onScheduleInterview?: (candidate: Candidate, interview: Interview) => void;
onSendEmail?: (candidate: Candidate, email: EmailLog) => void;
onAddScorecard?: (candidate: Candidate, scorecard: Scorecard) => void;
className?: string;
};
const NAV: { id: AtsView; label: string; icon: LucideIcon }[] = [
{ id: "jobs", label: "Jobs", icon: Briefcase },
{ id: "pipeline", label: "Pipeline", icon: Columns3 },
{ id: "interviews", label: "Interviews", icon: CalendarDays },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
];
type Dialog = { kind: "candidate"; id: string } | { kind: "schedule"; id: string; back?: boolean } | { kind: "reject"; ids: string[]; back?: string } | null;
export function RecruitingAtsApp({
initialData,
currentUserId = "u6",
today = SEED_TODAY,
defaultView = "pipeline",
onChange,
onCandidateStageChange,
onRejectCandidates,
onScheduleInterview,
onSendEmail,
onAddScorecard,
className,
}: RecruitingAtsAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const [data, setData] = React.useState<AtsData>(initialData ?? SEED_ATS);
const [view, setView] = React.useState<AtsView>(defaultView);
const [jobId, setJobId] = React.useState(data.jobs[0]?.id ?? "");
const [selected, setSelected] = React.useState<string[]>([]);
const [dialog, setDialog] = React.useState<Dialog>(null);
const [navOpen, setNavOpen] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
const { toasts, push, dismiss } = useToasts();
const me = data.interviewers.find((p) => p.id === currentUserId) ?? data.interviewers[0];
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(data);
}, [data]);
const patch = (id: string, fn: (c: Candidate) => Candidate) => setData((d) => ({ ...d, candidates: d.candidates.map((c) => (c.id === id ? fn(c) : c)) }));
const stageName = (s: StageId) => data.stages.find((x) => x.id === s)?.name ?? s;
const move = (id: string, to: StageId, via: "drag" | "keyboard" | "drawer") => {
const c = data.candidates.find((x) => x.id === id);
if (!c || c.stage === to) return;
const snapshot = data;
const from = c.stage;
const next = { ...c, stage: to, stageEnteredAt: today };
patch(id, () => next);
onCandidateStageChange?.(next, from, to);
const msg = to === "hired" ? `${c.name} hired` : `${c.name} moved to ${stageName(to)}`;
setAnnounce(msg);
push(msg, () => setData(snapshot));
if (via === "keyboard") requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[aria-label^="${CSS.escape(c.name)},"]`)?.focus());
};
const bulkMove = (ids: string[], to: StageId) => {
const snapshot = data;
setData((d) => ({ ...d, candidates: d.candidates.map((c) => (ids.includes(c.id) && c.stage !== to ? { ...c, stage: to, stageEnteredAt: today } : c)) }));
for (const c of data.candidates) if (ids.includes(c.id) && c.stage !== to) onCandidateStageChange?.({ ...c, stage: to }, c.stage, to);
setSelected([]);
push(`Moved ${ids.length} to ${stageName(to)}`, () => setData(snapshot));
};
const reject = (ids: string[], templateId: string, subject: string, body: string, reason: string) => {
const snapshot = data;
const emails: { candidateId: string; subject: string; body: string }[] = [];
const updated = new Map<string, Candidate>();
for (const c of data.candidates) {
if (!ids.includes(c.id)) continue;
const job = data.jobs.find((j) => j.id === c.jobId)?.title ?? "";
const s = mergeTemplate(subject, c, job, data.company, me.name);
const b = mergeTemplate(body, c, job, data.company, me.name);
emails.push({ candidateId: c.id, subject: s, body: b });
updated.set(c.id, { ...c, rejected: { at: today, reason }, emails: [...c.emails, { id: uid("e"), templateId, subject: s, body: b, at: today }] });
}
setData((d) => ({ ...d, candidates: d.candidates.map((c) => updated.get(c.id) ?? c) }));
onRejectCandidates?.(ids, templateId, emails);
setSelected((s) => s.filter((x) => !ids.includes(x)));
setDialog(null);
push(`Rejected ${ids.length} candidate${ids.length > 1 ? "s" : ""} · ${ids.length} email${ids.length > 1 ? "s" : ""} sent`, () => setData(snapshot));
};
const schedule = (id: string, iv: Omit<Interview, "id">, invite: boolean) => {
const c = data.candidates.find((x) => x.id === id);
if (!c) return;
const interview: Interview = { ...iv, id: uid("iv") };
const tpl = data.templates.find((t) => t.kind === "invite");
const job = data.jobs.find((j) => j.id === c.jobId)?.title ?? "";
const email: EmailLog | null = invite && tpl ? { id: uid("e"), templateId: tpl.id, subject: mergeTemplate(tpl.subject, c, job, data.company, me.name), body: mergeTemplate(tpl.body, c, job, data.company, me.name), at: today } : null;
patch(id, (x) => ({ ...x, interviews: [...x.interviews, interview], emails: email ? [...x.emails, email] : x.emails }));
onScheduleInterview?.(c, interview);
if (email) onSendEmail?.(c, email);
setDialog({ kind: "candidate", id });
push(`${iv.kind} booked for ${c.name.split(" ")[0]}${invite ? " · invite sent" : ""}`);
};
/* ------------------------------- shortcuts ------------------------------ */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
if (dialog || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement;
if (t.closest("input,textarea,select,[contenteditable]")) return;
if (!rootRef.current?.contains(t) && t !== document.body) return;
const n = Number(e.key);
if (n >= 1 && n <= NAV.length) {
e.preventDefault();
setView(NAV[n - 1].id);
} else if (e.key === "/") {
const s = rootRef.current?.querySelector<HTMLInputElement>('main input[type="search"]');
if (s) {
e.preventDefault();
s.focus();
}
} else if (e.key === "Escape" && selected.length) {
setSelected([]);
}
};
});
React.useEffect(() => {
const h = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
const openCand = dialog && dialog.kind !== "reject" ? data.candidates.find((c) => c.id === dialog.id) ?? null : null;
const rejectList = dialog?.kind === "reject" ? data.candidates.filter((c) => dialog.ids.includes(c.id)) : [];
const current = NAV.find((x) => x.id === view) ?? NAV[0];
const counts: Partial<Record<AtsView, number>> = {
jobs: data.jobs.filter((j) => j.status === "open").length,
pipeline: data.candidates.filter((c) => !c.rejected && c.stage !== "hired").length,
interviews: data.candidates.filter((c) => !c.rejected).reduce((a, c) => a + c.interviews.filter((i) => i.start.slice(0, 10) >= today).length, 0),
};
const currentJob = data.jobs.find((j) => j.id === jobId);
const nav = (
<nav aria-label="Recruiting" className="flex h-full flex-col">
<div className="flex h-14 items-center gap-2.5 px-4">
<span className="grid size-7 place-items-center rounded-lg bg-gradient-to-br from-primary to-primary/60 text-primary-foreground shadow-sm shadow-primary/30">
<Sparkles className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 truncate text-[13px] font-semibold">{data.company} Hiring</span>
</div>
<ul className="space-y-0.5 px-2">
{NAV.map((n, i) => {
const active = view === n.id;
return (
<li key={n.id}>
<button
type="button"
aria-current={active ? "page" : undefined}
title={`${n.label} (${i + 1})`}
onClick={() => {
setView(n.id);
setNavOpen(false);
}}
className={cn("relative flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13px] font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-ring", active ? "text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground")}
>
{active && <motion.span layoutId="ats-nav-pill" className="absolute inset-0 rounded-lg bg-background shadow-xs ring-1 ring-border dark:bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<n.icon className="relative size-4" aria-hidden />
<span className="relative flex-1 text-left">{n.label}</span>
{counts[n.id] !== undefined && <span className="relative text-[11px] tabular-nums text-muted-foreground">{counts[n.id]}</span>}
</button>
</li>
);
})}
</ul>
<div className="mx-3 mt-6">
<p className="mb-1.5 px-1 text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">Open roles</p>
<ul className="space-y-0.5">
{data.jobs
.filter((j) => j.status === "open")
.map((j) => (
<li key={j.id}>
<button
type="button"
onClick={() => {
setJobId(j.id);
setView("pipeline");
setNavOpen(false);
}}
className={cn("flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs outline-none transition hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring", view === "pipeline" && jobId === j.id ? "font-medium text-foreground" : "text-muted-foreground")}
>
<span className="min-w-0 flex-1 truncate">{j.title}</span>
<span className="tabular-nums">{data.candidates.filter((c) => c.jobId === j.id && !c.rejected).length}</span>
</button>
</li>
))}
</ul>
</div>
<div className="mt-auto flex items-center gap-2.5 border-t p-3">
<Avatar name={me.name} size="sm" />
<div className="min-w-0 text-xs">
<div className="truncate font-medium">{me.name}</div>
<div className="truncate text-muted-foreground">{me.title}</div>
</div>
</div>
</nav>
);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={dialog || navOpen ? true : undefined} className="flex min-w-0 flex-1">
<aside className="hidden w-56 shrink-0 border-r bg-muted/40 md:block dark:bg-muted/20">{nav}</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-5">
<IconButton label="Open navigation" className="md:hidden" onClick={() => setNavOpen(true)}>
<Menu className="size-4" />
</IconButton>
<current.icon className="hidden size-4 text-muted-foreground sm:block" aria-hidden />
<h2 className="text-sm font-semibold">{current.label}</h2>
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
·{" "}
{view === "jobs"
? `${counts.jobs} open roles`
: view === "pipeline"
? `${currentJob?.title ?? ""} · drag candidates between stages`
: view === "interviews"
? "Upcoming this week"
: "Hiring health"}
</span>
</header>
<div className="min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={view} className="h-full" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.08 } }} transition={{ duration: 0.16 }}>
{view === "jobs" && (
<JobsView
data={data}
today={today}
onOpenJob={(id) => {
setJobId(id);
setSelected([]);
setView("pipeline");
}}
/>
)}
{view === "pipeline" && (
<PipelineView
data={data}
jobId={jobId}
onJob={(id) => {
setJobId(id);
setSelected([]);
}}
today={today}
rootRef={rootRef}
selected={selected}
onSelected={setSelected}
onOpen={(id) => setDialog({ kind: "candidate", id })}
onMove={move}
onBulkMove={bulkMove}
onBulkReject={(ids) => setDialog({ kind: "reject", ids })}
/>
)}
{view === "interviews" && <InterviewsView data={data} today={today} onOpen={(id) => setDialog({ kind: "candidate", id })} />}
{view === "analytics" && <AnalyticsView data={data} today={today} />}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
<AnimatePresence>
{navOpen && (
<>
<motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setNavOpen(false)} className="absolute inset-0 z-40 bg-foreground/10 md:hidden dark:bg-black/50" />
<motion.aside
role="dialog"
aria-label="Navigation"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
onKeyDown={(e) => e.key === "Escape" && setNavOpen(false)}
className="absolute inset-y-0 left-0 z-50 w-64 border-r bg-background shadow-2xl md:hidden"
>
<IconButton label="Close navigation" className="absolute right-2 top-3 z-10" onClick={() => setNavOpen(false)}>
<X className="size-4" />
</IconButton>
{nav}
</motion.aside>
</>
)}
</AnimatePresence>
<Overlay open={dialog?.kind === "candidate" && !!openCand} onClose={() => setDialog(null)} label={openCand ? `Candidate: ${openCand.name}` : "Candidate"}>
{openCand && dialog?.kind === "candidate" && (
<CandidateDrawer
c={openCand}
data={data}
today={today}
meId={me.id}
onClose={() => setDialog(null)}
onMove={(s) => move(openCand.id, s, "drawer")}
onReject={() => setDialog({ kind: "reject", ids: [openCand.id], back: openCand.id })}
onSchedule={() => setDialog({ kind: "schedule", id: openCand.id, back: true })}
onAddNote={(text) => {
patch(openCand.id, (c) => ({ ...c, notes: [...c.notes, { id: uid("n"), authorId: me.id, at: `${today}T12:00:00`, text }] }));
push("Note added");
}}
onAddScorecard={(card) => {
const sc: Scorecard = { ...card, id: uid("sc"), at: today };
patch(openCand.id, (c) => ({ ...c, scorecards: [sc, ...c.scorecards] }));
onAddScorecard?.(openCand, sc);
push("Scorecard submitted");
}}
onSendEmail={(templateId, subject, body) => {
const email: EmailLog = { id: uid("e"), templateId, subject, body, at: today };
patch(openCand.id, (c) => ({ ...c, emails: [...c.emails, email] }));
onSendEmail?.(openCand, email);
push(`Email sent to ${openCand.name.split(" ")[0]}`);
}}
/>
)}
</Overlay>
<Overlay open={dialog?.kind === "schedule" && !!openCand} onClose={() => setDialog(openCand ? { kind: "candidate", id: openCand.id } : null)} label="Schedule interview" variant="dialog" className="max-w-xl max-sm:h-full max-sm:max-h-none max-sm:rounded-none">
{openCand && dialog?.kind === "schedule" && <Scheduler c={openCand} data={data} today={today} onCancel={() => setDialog({ kind: "candidate", id: openCand.id })} onConfirm={(iv, invite) => schedule(openCand.id, iv, invite)} />}
</Overlay>
<Overlay open={dialog?.kind === "reject" && rejectList.length > 0} onClose={() => setDialog(dialog?.kind === "reject" && dialog.back ? { kind: "candidate", id: dialog.back } : null)} label="Reject candidates" variant="dialog" className="max-w-xl max-sm:h-full max-sm:max-h-none max-sm:rounded-none">
{dialog?.kind === "reject" && rejectList.length > 0 && (
<BulkReject
candidates={rejectList}
data={data}
sender={me.name}
onCancel={() => setDialog(dialog.back ? { kind: "candidate", id: dialog.back } : null)}
onConfirm={(tid, subject, body, reason) => reject(dialog.ids, tid, subject, body, reason)}
/>
)}
</Overlay>
<div aria-live="assertive" className="sr-only">
{announce}
</div>
<Toasts toasts={toasts} dismiss={dismiss} />
</div>
</MotionConfig>
);
}
export default RecruitingAtsApp;