Ctrl K
ComunidadGuías…

src/components/pipeline/board.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import Link from "next/link";
import { createContext, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
import {
  DndContext,
  DragOverlay,
  MouseSensor,
  TouchSensor,
  useDraggable,
  useDroppable,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragStartEvent,
} from "@dnd-kit/core";
import { AnimatePresence, motion } from "framer-motion";
import {
  BuildingIcon,
  HandshakeIcon,
  CalendarClockIcon,
  ChevronsLeftRightIcon,
  ChevronsRightLeftIcon,
  FileTextIcon,
  MapPinIcon,
  MessageSquareIcon,
  MoonIcon,
} from "lucide-react";
import { toast } from "sonner";
import type { Stage } from "@prisma/client";
import { BOARD_STAGES, STAGE_LABEL, STAGE_TONE, TERMINAL_STAGES } from "@/lib/stages";
import { SHOW_QUIET_AFTER, STALE_AFTER, hasGoneQuiet } from "@/lib/quiet";
import { ApplicationActions } from "@/components/pipeline/application-actions";
import { CompanyAvatar } from "@/components/pipeline/company-avatar";
import { TagChip } from "@/components/tags/tag-chip";
import { formatMoney } from "@/components/pipeline/offer-card";
import { useOpenApplication } from "@/components/pipeline/application-panel";
import { cn, describeAdded, relativeDay } from "@/lib/utils";
import { useViewerZone } from "@/components/viewer-zone";
import { moveStageAction } from "@/server/actions";

/** Which columns you have folded away. A preference, not a filter. */
const COLLAPSED_KEY = "hired:board-collapsed";

export type Card = {
  id: string;
  company: string;
  roleTitle: string;
  stage: Stage;
  location: string;
  salaryRange: string;
  /**
   * What they actually offered, once somebody has. Replaces the advertised
   * range on the card when it exists: a real number beats a posting's range,
   * and showing both would make the card ask you which one is true.
   */
  offer: { total: number; currency: string } | null;
  nextFollowUpAt: string | null;
  resumeName: string | null;
  activityCount: number;
  /** Days since anything at all happened here. */
  quietDays: number;
  /** The posting, for the card's own actions. Empty when there never was one. */
  jobUrl: string;
  /** Null when logos are off, or no domain could be worked out. */
  domain: string | null;
  /** Off by default on a card: a board is scanned, not read. */
  tags: { id: string; name: string; color: string }[];
};

/**
 * Which optional fields a card draws, as context rather than a prop.
 *
 * A card is four components deep — board, column, draggable, card — and
 * threading a set through all four to be read only at the bottom is four
 * signatures changed for one value that never varies within a render.
 */
const VisibleFields = createContext<Set<string>>(new Set());
const useVisibleFields = () => useContext(VisibleFields);

export function PipelineBoard({
  open,
  closed,
  // Which columns to draw. Filtering to one stage should show that column on
  // its own rather than five empty ones beside it.
  columns = BOARD_STAGES,
  fields,
}: {
  open: Card[];
  closed: Card[];
  columns?: Stage[];
  /** What each card shows. Every key of BOARD_FIELDS that is turned on. */
  fields: string[];
}) {
  const shows = useMemo(() => new Set(fields), [fields]);
  const openPanel = useOpenApplication();
  const [cards, setCards] = useState(open);
  const [dragging, setDragging] = useState<Card | null>(null);
  const [, startTransition] = useTransition();
  // Mouse and touch are separated deliberately. One PointerSensor covered both,
  // and on a phone every gesture is a pointer gesture: a 6px movement is the
  // start of a scroll far more often than the start of a drag, so the board
  // grabbed a card whenever you tried to scroll past it and the column could
  // not be scrolled at all. Touch now needs a 220ms hold before a card moves,
  // which is the gesture a person already expects for "pick this up", and a
  // swipe below that threshold scrolls the way it should. Tapping a card still
  // opens the panel, where the stage Select moves it without any dragging.
  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 220, tolerance: 8 } }),
  );

  // Which stages are folded to a spine. Kept in localStorage rather than the
  // URL: it is how you like to look at the board, not what you are looking at,
  // so it should survive a navigation and should not travel in a shared link.
  // Read after mount so the server and the first client render agree.
  const [collapsed, setCollapsed] = useState<Set<Stage>>(new Set());
  useEffect(() => {
    try {
      const saved = window.localStorage.getItem(COLLAPSED_KEY);
      if (saved) setCollapsed(new Set(JSON.parse(saved) as Stage[]));
    } catch {
      // A malformed or unreadable value just means no collapsed columns.
    }
  }, []);

  const toggleColumn = (stage: Stage) =>
    setCollapsed((prev) => {
      const next = new Set(prev);
      if (next.has(stage)) next.delete(stage);
      else next.add(stage);
      try {
        window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next]));
      } catch {
        // Private mode: the preference just does not persist.
      }
      return next;
    });

  // Re-seed when the filter changes: `open` is a prop, and useState only reads
  // its initial value, so without this the board keeps showing the last cut.
  const [seed, setSeed] = useState(open);
  if (seed !== open) {
    setSeed(open);
    setCards(open);
  }

  const byStage = useMemo(() => {
    const map = new Map<Stage, Card[]>();
    for (const stage of columns) map.set(stage, []);
    for (const card of cards) map.get(card.stage)?.push(card);
    return map;
  }, [cards, columns]);

  const onDragStart = (event: DragStartEvent) => {
    setDragging(cards.find((card) => card.id === event.active.id) ?? null);
  };

  const onDragEnd = (event: DragEndEvent) => {
    setDragging(null);
    const target = event.over?.id as Stage | undefined;
    const id = String(event.active.id);
    if (!target) return;
    const card = cards.find((item) => item.id === id);
    if (!card || card.stage === target) return;

    // Optimistic: the card lands where you dropped it before the server replies.
    setCards((prev) => prev.map((item) => (item.id === id ? { ...item, stage: target } : item)));
    startTransition(async () => {
      try {
        const { addedTasks } = await moveStageAction(id, target);
        toast.success(`${card.company} → ${STAGE_LABEL[target]}`, {
          description: describeAdded(addedTasks),
        });
      } catch {
        setCards((prev) =>
          prev.map((item) => (item.id === id ? { ...item, stage: card.stage } : item)),
        );
        toast.error("Could not move that card.");
      }
    });
  };

  return (
    <VisibleFields.Provider value={shows}>
    <div className="space-y-8">
      <DndContext
        sensors={sensors}
        onDragStart={onDragStart}
        onDragEnd={onDragEnd}
        onDragCancel={() => setDragging(null)}
      >
        {columns.length > 0 && (
          <div className="no-scrollbar group/board -mx-4 flex gap-3 overflow-x-auto px-4 pb-2 md:-mx-8 md:px-8">
            {columns.map((stage) => (
              <Column
                key={stage}
                stage={stage}
                cards={byStage.get(stage) ?? []}
                collapsed={collapsed.has(stage)}
                onToggle={() => toggleColumn(stage)}
              />
            ))}
          </div>
        )}

        <DragOverlay dropAnimation={{ duration: 220, easing: "cubic-bezier(0.16,1,0.3,1)" }}>
          {dragging && (
            <div className="w-[15.5rem] rotate-2 opacity-95">
              <ApplicationCard card={dragging} overlay />
            </div>
          )}
        </DragOverlay>
      </DndContext>

      {closed.length > 0 && (
        <section>
          <h2 className="text-muted-foreground mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
            Closed · {closed.length}
          </h2>
          <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
            {closed.map((card) => (
              <Link
                key={card.id}
                href={`/applications/${card.id}`}
                onClick={(event) => {
                  if (!openPanel || event.metaKey || event.ctrlKey || event.shiftKey) return;
                  event.preventDefault();
                  openPanel(card.id);
                }}
              >
                <div className="bg-card shadow-hairline hover:bg-accent/40 flex items-center gap-2.5 rounded-card px-3 py-2 transition-colors duration-150">
                  <CompanyAvatar name={card.company} domain={card.domain} size={24} />
                  <div className="min-w-0 flex-1">
                    <div className="truncate text-[13px] font-medium">{card.company}</div>
                    <div className="text-faint truncate text-[12px]">{card.roleTitle}</div>
                  </div>
                  <span
                    className="stage-chip shrink-0 rounded-chip px-1.5 py-0.5 text-[11px] font-medium"
                    style={{ ["--tone" as string]: STAGE_TONE[card.stage] }}
                  >
                    {STAGE_LABEL[card.stage]}
                  </span>
                </div>
              </Link>
            ))}
          </div>
        </section>
      )}
    </div>
    </VisibleFields.Provider>
  );
}

function Column({
  stage,
  cards,
  collapsed,
  onToggle,
}: {
  stage: Stage;
  cards: Card[];
  collapsed: boolean;
  onToggle: () => void;
}) {
  const { setNodeRef, isOver } = useDroppable({ id: stage });

  // A collapsed column is still a drop target — the reason to collapse a stage
  // is that you are not working it, not that nothing may ever land there, and
  // a card dragged onto the spine should go in rather than snap back.
  if (collapsed) {
    return (
      <div
        ref={setNodeRef}
        className={cn(
          "flex w-11 shrink-0 flex-col items-center gap-2 rounded-xl py-2 transition-colors duration-200",
          isOver ? "bg-primary-tint shadow-[0_0_0_1px_var(--primary)]" : "bg-canvas shadow-hairline",
        )}
      >
        <button
          onClick={onToggle}
          aria-label={`Expand ${STAGE_LABEL[stage]}`}
          className="text-muted-foreground hover:text-foreground touch-target"
        >
          <ChevronsLeftRightIcon className="size-3.5" />
        </button>
        <span className="text-faint meta text-[12px]">{cards.length}</span>
        <span
          className="text-[12px] font-semibold whitespace-nowrap [writing-mode:vertical-rl]"
          style={{ color: STAGE_TONE[stage] }}
        >
          {STAGE_LABEL[stage]}
        </span>
      </div>
    );
  }

  return (
    <div className="flex w-[16rem] shrink-0 flex-col">
      <div className="mb-2.5 flex items-center gap-2 px-1">
        <span
          className="stage-chip rounded-chip px-1.5 py-0.5 text-[12px] font-semibold"
          style={{ ["--tone" as string]: STAGE_TONE[stage] }}
        >
          {STAGE_LABEL[stage]}
        </span>
        <span className="text-faint meta text-[12px]">{cards.length}</span>
        <button
          onClick={onToggle}
          aria-label={`Collapse ${STAGE_LABEL[stage]}`}
          className="text-faint hover:text-foreground touch-target ml-auto opacity-0 transition-opacity group-hover/board:opacity-100"
        >
          <ChevronsRightLeftIcon className="size-3.5" />
        </button>
      </div>

      <div
        ref={setNodeRef}
        className={cn(
          "bg-canvas flex min-h-[11rem] flex-1 flex-col gap-2 rounded-xl p-2 transition-[background-color,box-shadow] duration-200 ease-[var(--ease-settle)]",
          isOver ? "bg-primary-tint shadow-[0_0_0_1px_var(--primary)]" : "shadow-hairline",
        )}
      >
        <AnimatePresence initial={false}>
          {cards.map((card) => (
            <DraggableCard key={card.id} card={card} />
          ))}
        </AnimatePresence>

        {cards.length === 0 && (
          <div className="text-faint flex flex-1 items-center justify-center text-[12px]">
            {isOver ? "Drop here" : "Empty"}
          </div>
        )}
      </div>
    </div>
  );
}

function DraggableCard({ card }: { card: Card }) {
  // tabIndex -1 on the draggable, because the card inside it is already a
  // link and therefore already focusable. Without this every card is two tab
  // stops, the outer one announced as a draggable button that no registered
  // sensor can actually drag — the sensors below are mouse and touch only.
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
    id: card.id,
    attributes: { tabIndex: -1 },
  });
  // A drop is followed by a click on the element that was dragged — the
  // browser fires it on the pointerup regardless. Without this guard, filing a
  // card away would then open the panel for it. Armed during the drag, spent
  // on the click that follows, and re-cleared on the next press so a cancelled
  // drag (Escape) cannot leave it swallowing a genuine click later.
  const wasDragged = useRef(false);
  if (isDragging) wasDragged.current = true;

  return (
    <motion.div
      layout
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: isDragging ? 0.35 : 1, y: 0 }}
      exit={{ opacity: 0, scale: 0.95 }}
      transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      onPointerDownCapture={() => {
        wasDragged.current = false;
      }}
      // Enter on the card's link synthesizes a click with no pointerdown, so a
      // drag that ended off the card (its click landed elsewhere) would leave
      // the guard armed and eat the next keyboard activation.
      onKeyDownCapture={() => {
        wasDragged.current = false;
      }}
      onClickCapture={(event) => {
        if (!wasDragged.current) return;
        wasDragged.current = false;
        event.preventDefault();
        event.stopPropagation();
      }}
      // touch-none here meant "the browser may not scroll from this element",
      // and since the cards cover the board, that disabled scrolling over the
      // whole thing — vertically as well as sideways. touch-manipulation gives
      // scrolling back and still suppresses double-tap zoom; the TouchSensor's
      // 220ms hold is what distinguishes a drag now, so the browser no longer
      // has to be locked out to make dragging possible. select-none stops the
      // hold from starting a text selection instead of picking the card up.
      // The card is an anchor now, and iOS Safari answers a still ~500ms press
      // on a link with its native preview sheet — which fires touchcancel and
      // kills the drag the 220ms hold just started. touch-callout is the one
      // switch that suppresses it (contextmenu never fires for touch on iOS),
      // and it inherits down to the anchor from here.
      className="touch-manipulation select-none [-webkit-touch-callout:none]"
    >
      <ApplicationCard card={card} />
    </motion.div>
  );
}

function ApplicationCard({ card, overlay = false }: { card: Card; overlay?: boolean }) {
  const zone = useViewerZone();
  const shows = useVisibleFields();
  const overdue = card.nextFollowUpAt ? new Date(card.nextFollowUpAt) < new Date() : false;
  const openPanel = useOpenApplication();

  // The WHOLE card is the way in, not just the company's name. Opening a card
  // is what you do to it far more often than dragging it, so the click target
  // is the full surface: a plain click opens the panel, cmd/ctrl/shift-click
  // opens the page in a new tab, and a press that moves 6px is still a drag —
  // the sensors' activation thresholds are what keep the two gestures apart.
  const className = cn(
    "group bg-card relative block overflow-hidden rounded-card p-3 pr-8 pl-3.5 transition-shadow duration-200 ease-[var(--ease-settle)]",
    // A stripe of the stage's colour down the edge, so a column reads as
    // one thing at a glance and a mis-dropped card is obvious.
    "before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-[var(--tone)]",
    // The link is a transparent overlay, so the focus ring has to be drawn by
    // the card around it or keyboard focus would be invisible.
    "has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-[var(--ring)] has-[a:focus-visible]:outline-none",
    overlay
      ? "shadow-overlay"
      : "shadow-card hover:shadow-raised cursor-pointer active:cursor-grabbing",
  );
  const tone = { ["--tone" as string]: STAGE_TONE[card.stage] };

  const body = (
    <>
      <div className="flex items-start gap-2">
        <CompanyAvatar name={card.company} domain={card.domain} size={26} className="mt-0.5" />
        <div className="min-w-0 flex-1">
          <div className="truncate text-[13px] font-semibold group-hover:underline">
            {card.company}
          </div>
          <div className="text-faint truncate text-[12px]">{card.roleTitle}</div>
        </div>
      </div>

      {((shows.has("location") && card.location) ||
        (shows.has("salary") && (card.offer || card.salaryRange)) ||
        (shows.has("tags") && card.tags.length > 0)) && (
        <div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[11px]">
          {shows.has("location") && card.location && (
            <span className="flex items-center gap-1">
              <MapPinIcon className="size-2.5" />
              {card.location}
            </span>
          )}
          {shows.has("salary") &&
            (card.offer ? (
              <span
                className="nums flex items-center gap-1 font-medium"
                style={{ color: "var(--stage-offer)" }}
              >
                <HandshakeIcon className="size-2.5" />
                {formatMoney(card.offer.total, card.offer.currency)}
              </span>
            ) : (
              card.salaryRange && (
                <span className="flex items-center gap-1">
                  <BuildingIcon className="size-2.5" />
                  {card.salaryRange}
                </span>
              )
            ))}
          {shows.has("tags") &&
            card.tags.map((tag) => <TagChip key={tag.id} tag={tag} className="text-[10.5px]" />)}
        </div>
      )}

      <div className="mt-2.5 flex items-center gap-2">
        {shows.has("followUp") && card.nextFollowUpAt && (
          <span
            className={cn(
              "flex items-center gap-1 text-[11px]",
              overdue ? "text-destructive font-medium" : "text-muted-foreground",
            )}
          >
            <CalendarClockIcon className="size-2.5" />
            {relativeDay(new Date(card.nextFollowUpAt), zone)}
          </span>
        )}
        {/* How long since anything happened, which is the question the board
            existed to answer and could not. Below a week it is noise, so it
            says nothing; past the stage's own threshold it turns the colour
            of the diagnosis that agrees with it. */}
        {shows.has("quiet") &&
          card.quietDays >= SHOW_QUIET_AFTER &&
          STALE_AFTER[card.stage] !== undefined && (
          <span
            className={cn(
              "flex items-center gap-1 text-[11px]",
              hasGoneQuiet(card.stage, card.quietDays, TERMINAL_STAGES)
                ? "text-[var(--warning)] font-medium"
                : "text-muted-foreground",
            )}
            title={`Nothing logged for ${card.quietDays} days`}
          >
            <MoonIcon className="size-2.5" />
            {card.quietDays}d quiet
          </span>
          )}
        <div className="text-muted-foreground ml-auto flex items-center gap-2 text-[11px]">
          {shows.has("resume") && card.resumeName && <FileTextIcon className="size-2.5" />}
          {shows.has("activity") && card.activityCount > 0 && (
            <span className="flex items-center gap-0.5">
              <MessageSquareIcon className="size-2.5" />
              {card.activityCount}
            </span>
          )}
        </div>
      </div>
    </>
  );

  // The overlay copy that follows the pointer is decoration, not a control.
  if (overlay) {
    return (
      <div style={tone} className={className}>
        {body}
      </div>
    );
  }

  // The card is a div with a stretched link inside rather than a link wrapping
  // everything, because the actions menu is a button and a button inside an
  // anchor is invalid — the same arrangement the contacts list uses. The
  // overlay keeps the whole surface clickable; the menu is positioned, so it
  // paints above the overlay and stays clickable in its own right.
  return (
    <div style={tone} className={className}>
      <Link
        href={`/applications/${card.id}`}
        // An anchor is natively draggable, and the browser's link-drag would
        // wrestle dnd-kit for the gesture.
        draggable={false}
        data-nav-item
        aria-label={`${card.company} — ${card.roleTitle}`}
        onClick={(event) => {
          if (!openPanel || event.metaKey || event.ctrlKey || event.shiftKey) return;
          event.preventDefault();
          openPanel(card.id);
        }}
        className="absolute inset-0 z-0"
      />
      <div className="pointer-events-none relative">{body}</div>
      <div className="absolute top-1.5 right-1.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 max-md:opacity-100">
        <ApplicationActions
          application={{
            id: card.id,
            company: card.company,
            roleTitle: card.roleTitle,
            stage: card.stage,
            jobUrl: card.jobUrl,
          }}
        />
      </div>
    </div>
  );
}

export { TERMINAL_STAGES };