Fazekit

Code

"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;

More in Business

View all →