"use client"; import { useState, useTransition } from "react"; import { CalendarIcon, ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import type { InterviewFormat, InterviewOutcome, QuestionKind } from "@prisma/client"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { DateField } from "@/components/ui/date-field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { SaveIndicator } from "@/components/save-indicator"; import { useAutosave } from "@/hooks/use-autosave"; import { useViewerZone } from "@/components/viewer-zone"; import { civilDay, shortCivilDay } from "@/lib/time"; import { cn } from "@/lib/utils"; import { addInterviewQuestionAction, deleteInterviewAction, deleteInterviewQuestionAction, scheduleInterviewAction, updateInterviewAction, updateInterviewQuestionAction, } from "@/server/actions"; /** * Rounds, on the application they belong to. * * One tab, not a screen: an interview is a fact about one job, and the reason * to look at it is always "what is coming up here" or "what did they ask me * last time". Both of those are questions about the thing already open. * * The questions are the point. Everything else on this panel is scaffolding for * getting them written down while somebody still remembers them, which is the * hour after an interview and never again. */ export type InterviewQuestionValue = { id: string; kind: QuestionKind; question: string; answer: string; confidence: number; better: string; }; export type InterviewValue = { id: string; round: number; label: string; format: InterviewFormat; outcome: InterviewOutcome; scheduledAt: string | null; durationMins: number; location: string; prep: string; debrief: string; interviewers: { id: string; name: string }[]; questions: InterviewQuestionValue[]; }; const FORMAT_LABEL: Record = { PHONE: "Phone", VIDEO: "Video call", ONSITE: "Onsite", TAKE_HOME: "Take-home", PAIRING: "Pairing", PANEL: "Panel", OTHER: "Other", }; const OUTCOME_LABEL: Record = { SCHEDULED: "Scheduled", HELD: "Held, no word yet", PASSED: "Passed", REJECTED: "Rejected", CANCELLED: "Cancelled", NO_SHOW: "They did not show", }; const QUESTION_LABEL: Record = { BEHAVIOURAL: "Behavioural", TECHNICAL: "Technical", SYSTEM_DESIGN: "System design", ROLE: "About the role", CULTURE: "Culture", COMPENSATION: "Money", MINE: "You asked them", OTHER: "Other", }; const FORMATS = Object.keys(FORMAT_LABEL) as InterviewFormat[]; const OUTCOMES = Object.keys(OUTCOME_LABEL) as InterviewOutcome[]; const KINDS = Object.keys(QUESTION_LABEL) as QuestionKind[]; /** Outcomes that mean it is over, so the badge can stop looking hopeful. */ const TONE: Record = { SCHEDULED: "border-primary/40 text-primary", HELD: "border-warning/40 text-warning", PASSED: "border-success/40 text-success", REJECTED: "border-destructive/40 text-destructive", CANCELLED: "text-muted-foreground", NO_SHOW: "text-muted-foreground", }; export function InterviewsPanel({ applicationId, interviews, }: { applicationId: string; interviews: InterviewValue[]; }) { const zone = useViewerZone(); const [openId, setOpenId] = useState(interviews[0]?.id ?? null); const [pending, start] = useTransition(); const add = () => start(async () => { try { const id = await scheduleInterviewAction(applicationId, {}); setOpenId(id); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not add that round."); } }); const remove = (id: string, questions: number) => start(async () => { const warning = questions > 0 ? `Delete this round and the ${questions} question${questions === 1 ? "" : "s"} recorded against it? There is no archive for interviews.` : "Delete this round? There is no archive for interviews."; if (!window.confirm(warning)) return; try { await deleteInterviewAction(id); if (openId === id) setOpenId(null); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not delete that."); } }); return (

{interviews.length === 0 ? "No rounds recorded. Add one when a date is agreed, and write down what they asked straight afterwards — that is what makes the next one easier." : `${interviews.length} round${interviews.length === 1 ? "" : "s"}`}

{interviews.map((interview) => { const open = interview.id === openId; const day = interview.scheduledAt ? shortCivilDay(civilDay(new Date(interview.scheduledAt), zone)) : "No date"; return (
{open && ( remove(interview.id, interview.questions.length)} busy={pending} /> )}
); })}
); } function InterviewEditor({ interview, onDelete, busy, }: { interview: InterviewValue; onDelete: () => void; busy: boolean; }) { const [values, setValues] = useState({ label: interview.label, format: interview.format, outcome: interview.outcome, scheduledAt: interview.scheduledAt ?? "", durationMins: interview.durationMins, location: interview.location, prep: interview.prep, debrief: interview.debrief, }); const [asking, setAsking] = useState(""); const [pending, start] = useTransition(); const save = useAutosave(async (next) => { await updateInterviewAction(interview.id, { label: next.label, format: next.format, outcome: next.outcome, scheduledAt: next.scheduledAt, durationMins: next.durationMins, location: next.location, prep: next.prep, debrief: next.debrief, }); }); const set = (patch: Partial) => { const next = { ...values, ...patch }; setValues(next); save.push(next); }; const addQuestion = () => { const question = asking.trim(); if (!question) return; setAsking(""); start(async () => { try { await addInterviewQuestionAction(interview.id, { question }); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not add that question."); } }); }; return (
set({ label: event.target.value })} />
set({ scheduledAt })} />
set({ location: event.target.value })} />
set({ durationMins: Number(event.target.value) || 0 })} />
{interview.interviewers.length > 0 && (
{interview.interviewers.map((person) => ( {person.name} ))}
)}