Ctrl K
ComunidadGuías…

src/components/pipeline/interviews-panel.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<InterviewFormat, string> = {
  PHONE: "Phone",
  VIDEO: "Video call",
  ONSITE: "Onsite",
  TAKE_HOME: "Take-home",
  PAIRING: "Pairing",
  PANEL: "Panel",
  OTHER: "Other",
};

const OUTCOME_LABEL: Record<InterviewOutcome, string> = {
  SCHEDULED: "Scheduled",
  HELD: "Held, no word yet",
  PASSED: "Passed",
  REJECTED: "Rejected",
  CANCELLED: "Cancelled",
  NO_SHOW: "They did not show",
};

const QUESTION_LABEL: Record<QuestionKind, string> = {
  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<InterviewOutcome, string> = {
  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<string | null>(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 (
    <div className="@container space-y-4">
      <div className="flex items-center justify-between gap-3">
        <p className="text-muted-foreground text-xs">
          {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"}`}
        </p>
        <Button size="sm" onClick={add} disabled={pending}>
          <PlusIcon /> Add a round
        </Button>
      </div>

      {interviews.map((interview) => {
        const open = interview.id === openId;
        const day = interview.scheduledAt
          ? shortCivilDay(civilDay(new Date(interview.scheduledAt), zone))
          : "No date";
        return (
          <div key={interview.id} className="border-border/70 rounded-lg border">
            <button
              type="button"
              onClick={() => setOpenId(open ? null : interview.id)}
              className="hover:bg-muted/40 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors"
            >
              <ChevronDownIcon
                className={cn("text-faint size-4 shrink-0 transition-transform", !open && "-rotate-90")}
              />
              <span className="truncate text-[13px] font-medium">
                {interview.label || `Round ${interview.round}`}
              </span>
              <Badge variant="outline" className={cn("text-[10px]", TONE[interview.outcome])}>
                {OUTCOME_LABEL[interview.outcome]}
              </Badge>
              <span className="text-faint ml-auto flex items-center gap-1.5 text-[11px] whitespace-nowrap">
                <CalendarIcon className="size-3" />
                {day}
                {interview.questions.length > 0 && (
                  <span className="nums">
                    · {interview.questions.length} q{interview.questions.length === 1 ? "" : "s"}
                  </span>
                )}
              </span>
            </button>

            {open && (
              <InterviewEditor
                key={interview.id}
                interview={interview}
                onDelete={() => remove(interview.id, interview.questions.length)}
                busy={pending}
              />
            )}
          </div>
        );
      })}
    </div>
  );
}

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<typeof values>(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<typeof values>) => {
    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 (
    <div className="border-border/70 space-y-4 border-t px-3 py-4">
      <div className="flex items-center justify-between gap-3">
        <SaveIndicator state={save.state} />
        <Button
          variant="ghost"
          size="icon-sm"
          className="text-faint hover:text-destructive"
          onClick={onDelete}
          disabled={busy}
          aria-label="Delete this round"
        >
          <Trash2Icon />
        </Button>
      </div>

      <div className="grid grid-cols-1 gap-3 @min-[34rem]:grid-cols-2">
        <div className="space-y-1.5">
          <Label>What it is called</Label>
          <Input
            value={values.label}
            placeholder={`Round ${interview.round}`}
            onChange={(event) => set({ label: event.target.value })}
          />
        </div>
        <div className="space-y-1.5">
          <Label>Format</Label>
          <Select value={values.format} onValueChange={(format) => set({ format: format as InterviewFormat })}>
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {FORMATS.map((format) => (
                <SelectItem key={format} value={format}>
                  {FORMAT_LABEL[format]}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="space-y-1.5">
          <Label>When</Label>
          <DateField
            value={values.scheduledAt}
            ariaLabel="When this round is"
            onChange={(scheduledAt) => set({ scheduledAt })}
          />
        </div>
        <div className="space-y-1.5">
          <Label>How it went</Label>
          <Select
            value={values.outcome}
            onValueChange={(outcome) => set({ outcome: outcome as InterviewOutcome })}
          >
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {OUTCOMES.map((outcome) => (
                <SelectItem key={outcome} value={outcome}>
                  {OUTCOME_LABEL[outcome]}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="space-y-1.5">
          <Label>Where, or the link</Label>
          <Input
            value={values.location}
            placeholder="Zoom, or their office"
            onChange={(event) => set({ location: event.target.value })}
          />
        </div>
        <div className="space-y-1.5">
          <Label>How long, in minutes</Label>
          <Input
            type="number"
            min={0}
            value={values.durationMins || ""}
            onChange={(event) => set({ durationMins: Number(event.target.value) || 0 })}
          />
        </div>
      </div>

      {interview.interviewers.length > 0 && (
        <div className="space-y-1.5">
          <Label>In the room</Label>
          <div className="flex flex-wrap gap-1.5">
            {interview.interviewers.map((person) => (
              <Badge key={person.id} variant="secondary" className="text-[11px]">
                {person.name}
              </Badge>
            ))}
          </div>
        </div>
      )}

      <div className="space-y-1.5">
        <Label>Before — what to say, what to ask</Label>
        <Textarea
          value={values.prep}
          rows={3}
          placeholder="What to bring up, what to avoid, what to ask them."
          onChange={(event) => set({ prep: event.target.value })}
        />
      </div>

      <div className="space-y-1.5">
        <Label>After — how it actually went</Label>
        <Textarea
          value={values.debrief}
          rows={3}
          placeholder="Write this within the hour. Nobody remembers an interview two days later."
          onChange={(event) => set({ debrief: event.target.value })}
        />
      </div>

      <div className="space-y-2">
        <Label>What they asked</Label>
        {interview.questions.map((question) => (
          <QuestionRow key={question.id} question={question} busy={pending} />
        ))}

        <div className="flex gap-2">
          <Input
            value={asking}
            placeholder="Add a question they asked, in their words"
            onChange={(event) => setAsking(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") {
                event.preventDefault();
                addQuestion();
              }
            }}
          />
          <Button variant="outline" onClick={addQuestion} disabled={pending || !asking.trim()}>
            Add
          </Button>
        </div>
      </div>
    </div>
  );
}

function QuestionRow({ question, busy }: { question: InterviewQuestionValue; busy: boolean }) {
  const [values, setValues] = useState({
    kind: question.kind,
    question: question.question,
    answer: question.answer,
    confidence: question.confidence,
    better: question.better,
  });
  const [, start] = useTransition();

  const save = useAutosave<typeof values>(async (next) => {
    await updateInterviewQuestionAction(question.id, next);
  });

  const set = (patch: Partial<typeof values>) => {
    const next = { ...values, ...patch };
    setValues(next);
    save.push(next);
  };

  return (
    <div className="bg-muted/30 space-y-2 rounded-md p-2.5">
      <div className="flex items-start gap-2">
        <Input
          value={values.question}
          className="h-8 text-[13px]"
          onChange={(event) => set({ question: event.target.value })}
        />
        <Select value={values.kind} onValueChange={(kind) => set({ kind: kind as QuestionKind })}>
          <SelectTrigger className="h-8 w-40 shrink-0 text-[12px]">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {KINDS.map((kind) => (
              <SelectItem key={kind} value={kind}>
                {QUESTION_LABEL[kind]}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Button
          variant="ghost"
          size="icon-sm"
          className="text-faint hover:text-destructive shrink-0"
          disabled={busy}
          onClick={() => start(async () => void (await deleteInterviewQuestionAction(question.id)))}
          aria-label="Remove this question"
        >
          <Trash2Icon />
        </Button>
      </div>
      <Textarea
        value={values.answer}
        rows={2}
        placeholder="What you actually said — not a better version. The better version goes below."
        className="text-[13px]"
        onChange={(event) => set({ answer: event.target.value })}
      />
      <div className="flex items-center gap-3">
        <Label className="text-[11px]">How it went</Label>
        <div className="flex gap-1">
          {[1, 2, 3, 4, 5].map((score) => (
            <button
              key={score}
              type="button"
              onClick={() => set({ confidence: values.confidence === score ? 0 : score })}
              className={cn(
                "size-6 rounded text-[11px] transition-colors",
                values.confidence >= score
                  ? "bg-primary text-primary-foreground"
                  : "bg-background text-faint hover:bg-muted",
              )}
              aria-label={`Rate this answer ${score} out of 5`}
            >
              {score}
            </button>
          ))}
        </div>
      </div>
      <Textarea
        value={values.better}
        rows={2}
        placeholder="What you wish you had said. This is the field that turns a bad round into the next one."
        className="text-[13px]"
        onChange={(event) => set({ better: event.target.value })}
      />
    </div>
  );
}