Ctrl K
ComunidadGuías…

src/components/pipeline/application-actions.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
  ClockIcon,
  ExternalLinkIcon,
  MessageSquarePlusIcon,
  MoreVerticalIcon,
  Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ACTIVITY_LABEL, ACTIVITY_OPTIONS, STAGES, STAGE_LABEL, STAGE_TONE } from "@/lib/stages";
import type { ActivityType, Stage } from "@prisma/client";
import {
  addActivityAction,
  deleteApplicationAction,
  logFollowUpAction,
  moveStageAction,
  snoozeFollowUpAction,
} from "@/server/actions";
import { cn } from "@/lib/utils";
import { linkHref } from "@/lib/social";

export type ActionTarget = {
  id: string;
  company: string;
  roleTitle: string;
  stage: Stage;
  jobUrl: string;
};

/**
 * The verbs, on the object you are already looking at.
 *
 * The board had none at all: moving a stage meant dragging, and logging what
 * happened meant opening the panel, finding the box and closing it again. One
 * component for the board card and the list row, because two menus offering
 * different things on the same application is how a product starts feeling
 * arbitrary.
 *
 * Every handler stops the event rather than preventing it. Radix composes its
 * own trigger handler after the child's and skips it entirely when the event
 * is already defaultPrevented, so preventDefault here would open nothing —
 * and the failure is silent.
 */
export function ApplicationActions({
  application,
  onDone,
  className,
}: {
  application: ActionTarget;
  /** Called after any write, so a board can refresh without a full reload. */
  onDone?: () => void;
  className?: string;
}) {
  const router = useRouter();
  const [pending, startTransition] = useTransition();
  const [logging, setLogging] = useState(false);
  const [body, setBody] = useState("");
  const [type, setType] = useState<ActivityType>("NOTE");
  const posting = linkHref(application.jobUrl);

  const run = (work: () => Promise<unknown>, done: string) => {
    startTransition(async () => {
      try {
        await work();
        toast.success(done);
        onDone?.();
        router.refresh();
      } catch (error) {
        toast.error(error instanceof Error ? error.message : "That did not work.");
      }
    });
  };

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={`Actions for ${application.company}`}
            className={cn("text-faint hover:text-foreground", className)}
            // The board's sensors are MouseSensor and TouchSensor, which
            // listen for mousedown and touchstart — not pointerdown — so
            // stopping only the pointer event let a press on the menu start a
            // drag. Stopped rather than prevented: Radix skips its own trigger
            // handler when the event is already defaultPrevented, and the
            // menu would silently never open.
            onPointerDown={(event) => event.stopPropagation()}
            onMouseDown={(event) => event.stopPropagation()}
            onTouchStart={(event) => event.stopPropagation()}
            onClick={(event) => event.stopPropagation()}
          >
            <MoreVerticalIcon />
          </Button>
        </DropdownMenuTrigger>
        {/* The menu and the dialog portal out of the card, but React's
            synthetic events still bubble through the React tree — so a press
            inside either would reach the draggable that rendered them. */}
        <DropdownMenuContent
          align="end"
          onClick={(event) => event.stopPropagation()}
          onMouseDown={(event) => event.stopPropagation()}
          onTouchStart={(event) => event.stopPropagation()}
        >
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>Move to</DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              {STAGES.map((stage) => (
                <DropdownMenuItem
                  key={stage}
                  disabled={pending || stage === application.stage}
                  onSelect={() =>
                    run(
                      () => moveStageAction(application.id, stage),
                      `${application.company} → ${STAGE_LABEL[stage]}`,
                    )
                  }
                >
                  <span
                    className="size-2 shrink-0 rounded-full"
                    style={{ background: STAGE_TONE[stage] }}
                  />
                  {STAGE_LABEL[stage]}
                </DropdownMenuItem>
              ))}
            </DropdownMenuSubContent>
          </DropdownMenuSub>

          <DropdownMenuItem onSelect={() => setLogging(true)}>
            <MessageSquarePlusIcon /> Log what happened
          </DropdownMenuItem>

          <DropdownMenuSub>
            <DropdownMenuSubTrigger>
              <ClockIcon /> Follow-up
            </DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuItem
                disabled={pending}
                onSelect={() =>
                  run(() => logFollowUpAction(application.id), "Logged, and back in a week")
                }
              >
                Chased it — log and come back in a week
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              {[3, 7, 14].map((days) => (
                <DropdownMenuItem
                  key={days}
                  disabled={pending}
                  onSelect={() =>
                    run(() => snoozeFollowUpAction(application.id, days), `Snoozed ${days} days`)
                  }
                >
                  Push out {days} days
                </DropdownMenuItem>
              ))}
            </DropdownMenuSubContent>
          </DropdownMenuSub>

          {posting && (
            <DropdownMenuItem asChild>
              <a href={posting} target="_blank" rel="noreferrer noopener">
                <ExternalLinkIcon /> Open the posting
              </a>
            </DropdownMenuItem>
          )}

          <DropdownMenuSeparator />
          <DropdownMenuItem
            variant="destructive"
            onSelect={() => {
              if (
                !confirm(
                  `Delete the ${application.company} application? Its timeline and tasks go with it, and you can restore all of it from the archive.`,
                )
              )
                return;
              run(() => deleteApplicationAction(application.id), "Deleted");
            }}
          >
            <Trash2Icon /> Delete
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>

      <Dialog open={logging} onOpenChange={setLogging}>
        <DialogContent
          className="max-w-lg"
          onClick={(event) => event.stopPropagation()}
          onMouseDown={(event) => event.stopPropagation()}
          onTouchStart={(event) => event.stopPropagation()}
        >
          <DialogHeader>
            <DialogTitle>Log what happened</DialogTitle>
            <DialogDescription>
              {application.company} — {application.roleTitle}
            </DialogDescription>
          </DialogHeader>
          <div className="space-y-2">
            <Textarea
              autoFocus
              value={body}
              onChange={(event) => setBody(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
                  event.preventDefault();
                  if (body.trim()) {
                    run(
                      () => addActivityAction({ applicationId: application.id, type, body: body.trim() }),
                      "Logged",
                    );
                    setBody("");
                    setLogging(false);
                  }
                }
              }}
              placeholder="Recruiter call went well — they want a system design round next week."
              className="min-h-24"
            />
            <Select value={type} onValueChange={(value) => setType(value as ActivityType)}>
              <SelectTrigger size="sm" className="w-40" aria-label="Kind of touch">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {ACTIVITY_OPTIONS.map((option) => (
                  <SelectItem key={option} value={option}>
                    {ACTIVITY_LABEL[option]}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <DialogFooter>
            <Button variant="ghost" onClick={() => setLogging(false)}>
              Cancel
            </Button>
            <Button
              variant="default"
              disabled={!body.trim() || pending}
              onClick={() => {
                run(
                  () => addActivityAction({ applicationId: application.id, type, body: body.trim() }),
                  "Logged",
                );
                setBody("");
                setLogging(false);
              }}
            >
              Log it
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}