Ctrl K
ComunidadGuías…

src/components/pipeline/new-application-dialog.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { LoaderCircleIcon, PlusIcon, SparklesIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { BOARD_STAGES, STAGE_LABEL } from "@/lib/stages";
import type { Stage } from "@prisma/client";
import { createApplicationAction, parsePostingAction } from "@/server/actions";
import { TagPicker, type TagOption } from "@/components/tags/tag-picker";
import { cn } from "@/lib/utils";
import { DateField } from "@/components/ui/date-field";
import { ValuePicker } from "@/components/pipeline/value-picker";
import type { TagValue } from "@/components/tags/tag-chip";

export function NewApplicationDialog({
  resumes,
  tagOptions,
  fieldValues,
}: {
  resumes: { id: string; name: string }[];
  /** Every source category on file, with usage counts. */
  tagOptions: TagOption[];
  /** Locations already in use, so the first application matches the others. */
  fieldValues: { location: { value: string; count: number }[] };
}) {
  const router = useRouter();
  const params = useSearchParams();
  const [open, setOpen] = useState(false);
  const [pending, startTransition] = useTransition();
  const [fetching, startFetching] = useTransition();
  const [form, setForm] = useState({
    company: "",
    roleTitle: "",
    stage: "" as Stage | "",
    appliedAt: "",
    jobUrl: "",
    location: "",
    salaryRange: "",
    tags: [] as TagValue[],
    jobDescription: "",
    resumeId: "",
  });

  useEffect(() => {
    if (params.get("new")) setOpen(true);
    // "Track a role" on a company page lands here with the company filled in.
    const company = params.get("company");
    if (company) setForm((current) => (current.company ? current : { ...current, company }));
  }, [params]);

  // Paste a link, get the form filled. The fields stay editable — the page's
  // answer is a draft, not a decision.
  const fetchPosting = () => {
    const url = form.jobUrl.trim();
    if (!url) {
      toast.error("Paste the posting's link first.");
      return;
    }
    startFetching(async () => {
      const result = await parsePostingAction(url);
      if (!result.ok) {
        toast.error(result.error);
        return;
      }
      const parsed = result.parsed;
      setForm((current) => ({
        ...current,
        company: current.company || parsed.company,
        roleTitle: current.roleTitle || parsed.roleTitle,
        location: current.location || parsed.location,
        salaryRange: current.salaryRange || parsed.salaryRange,
        // The parser hands back a name. Only prefill it when it matches a tag
        // that already exists — a capture should not invent one from a job
        // board's own wording.
        tags:
          current.tags.length > 0 || !parsed.source
            ? current.tags
            : (tagOptions ?? []).filter(
                (option) => option.name.toLowerCase() === parsed.source.toLowerCase(),
              ),
        jobDescription: current.jobDescription || parsed.jobDescription,
      }));
      toast.success(
        parsed.roleTitle && parsed.company
          ? "Filled from the posting — check it over."
          : "Read what it could; the rest is yours to fill.",
      );
    });
  };

  /** Everything past "applied" — the rare case, behind one more click. */
  const further: Stage[] = BOARD_STAGES.filter(
    (stage) => stage !== "WISHLIST" && stage !== "APPLIED",
  );

  const submit = () => {
    if (!form.company.trim() || !form.roleTitle.trim()) {
      toast.error("Company and role are required.");
      return;
    }
    if (!form.stage) {
      toast.error("Say whether you have applied yet — it decides where the card goes.");
      return;
    }
    // Narrowed above, but the guard does not survive the spread — so the
    // stage is written explicitly rather than carried along in `rest`.
    const stage = form.stage;
    startTransition(async () => {
      // Unhandled before this: a stale resume id, a tag ownership check or a
      // server asleep on a small box rejected into nowhere. The spinner
      // stopped, everything they had typed stayed on screen, nothing was
      // saved and nothing said so — so they pressed the button again.
      try {
        const { tags, ...rest } = form;
        const id = await createApplicationAction({
          ...rest,
          stage,
          company: form.company.trim(),
          roleTitle: form.roleTitle.trim(),
          appliedAt: form.appliedAt || undefined,
          tagIds: tags.map((tag) => tag.id),
          resumeId: form.resumeId || null,
        });
        setOpen(false);
        toast.success("Tracking it");
        router.push(`/applications/${id}`);
      } catch (error) {
        toast.error(error instanceof Error ? error.message : "Could not track that job.");
      }
    });
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="default" data-new-button>
          <PlusIcon /> Track a job
        </Button>
      </DialogTrigger>
      <DialogContent className="max-w-xl">
        <DialogHeader>
          <DialogTitle>Track a job</DialogTitle>
          <DialogDescription>
            Paste the posting and Claude can tailor a resume against it later. No link is fine
            too — a role you&apos;re chasing through a DM, with no listing, still belongs here.
          </DialogDescription>
        </DialogHeader>

        <div className="grid gap-4 sm:grid-cols-2">
          <div className="space-y-1.5 sm:col-span-2">
            <Label htmlFor="app-url">Job link</Label>
            <div className="flex gap-2">
              <Input
                id="app-url"
                value={form.jobUrl}
                onChange={(event) => setForm({ ...form, jobUrl: event.target.value })}
                onKeyDown={(event) => {
                  if (event.key === "Enter") fetchPosting();
                }}
                placeholder="https://…  — paste a posting and let it fill the form"
              />
              <Button variant="outline" onClick={fetchPosting} disabled={fetching}>
                {fetching ? <LoaderCircleIcon className="animate-spin" /> : <SparklesIcon />}
                Fill from link
              </Button>
            </div>
          </div>

          <div className="space-y-1.5">
            <Label htmlFor="app-company">Company</Label>
            <Input
              id="app-company"
              autoFocus
              value={form.company}
              onChange={(event) => setForm({ ...form, company: event.target.value })}
              onKeyDown={(event) => {
                if (event.key === "Enter") submit();
              }}
              placeholder="Stripe"
            />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="app-role">Role</Label>
            <Input
              id="app-role"
              value={form.roleTitle}
              onChange={(event) => setForm({ ...form, roleTitle: event.target.value })}
              onKeyDown={(event) => {
                if (event.key === "Enter") submit();
              }}
              placeholder="Staff Engineer, Payments"
            />
          </div>

          {/* A question, not a dropdown of seven board columns.
              This defaulted to Wishlist, and both possible defaults were
              wrong. Left alone by somebody who HAD applied, the card sat in
              Wishlist, never entered the funnel, and their analytics said they
              had applied to nothing. Defaulting the other way invents
              applications nobody sent. So it is asked instead — two buttons,
              plain words, no default and no submit until one is picked. It is
              one click, and it is the click that makes every number downstream
              true. "Further along" opens the full ladder for the rarer case of
              somebody already interviewing when they start tracking. */}
          <div className="space-y-1.5 sm:col-span-2">
            <Label>Have you applied yet?</Label>
            <div className="flex flex-wrap gap-2">
              <StageChoice
                active={form.stage === "WISHLIST"}
                onClick={() => setForm({ ...form, stage: "WISHLIST" })}
              >
                Not yet — just saving it
              </StageChoice>
              <StageChoice
                active={form.stage === "APPLIED"}
                onClick={() => setForm({ ...form, stage: "APPLIED" })}
              >
                Yes, I have applied
              </StageChoice>
              <Select
                value={further.includes(form.stage as Stage) ? form.stage : ""}
                onValueChange={(value) => setForm({ ...form, stage: value as Stage })}
              >
                <SelectTrigger
                  aria-label="Somewhere further along"
                  className={cn(
                    "h-auto w-auto min-w-0 gap-1.5 rounded-control px-3 py-1.5 text-[13px]",
                    further.includes(form.stage as Stage)
                      ? "border-primary bg-primary-tint text-foreground"
                      : "text-muted-foreground",
                  )}
                >
                  <SelectValue placeholder="Further along" />
                </SelectTrigger>
                <SelectContent>
                  {further.map((stage) => (
                    <SelectItem key={stage} value={stage}>
                      {STAGE_LABEL[stage]}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>

          {/* Only once the answer above is "yes". The first session is almost
              always a backfill — five or ten jobs sent over the past month —
              and every one of them used to be stamped with today, which dated
              the follow-up reminders wrong too. Blank means today, the way it
              always did. */}
          {form.stage && form.stage !== "WISHLIST" && (
            <div className="space-y-1.5">
              <Label>Applied on</Label>
              <DateField
                value={form.appliedAt}
                onChange={(appliedAt) => setForm({ ...form, appliedAt })}
                ariaLabel="Applied on"
                placeholder="Today"
              />
            </div>
          )}

          <div className="space-y-1.5">
            <Label>Resume used</Label>
            <Select
              value={form.resumeId || "none"}
              onValueChange={(value) => setForm({ ...form, resumeId: value === "none" ? "" : value })}
            >
              <SelectTrigger aria-label="Resume used">
                <SelectValue placeholder="None yet" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="none">None yet</SelectItem>
                {resumes.map((resume) => (
                  <SelectItem key={resume.id} value={resume.id}>
                    {resume.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          <div className="space-y-1.5">
            <Label>Location</Label>
            <ValuePicker
              value={form.location}
              options={fieldValues.location}
              onChange={(location) => setForm({ ...form, location })}
              placeholder="Anywhere"
              ariaLabel="Location"
            />
          </div>
          <div className="space-y-1.5">
            <Label htmlFor="app-salary">Compensation</Label>
            <Input
              id="app-salary"
              value={form.salaryRange}
              onChange={(event) => setForm({ ...form, salaryRange: event.target.value })}
              placeholder="$180k – $230k"
            />
          </div>

          <div className="space-y-1.5">
            <Label>Tags</Label>
            <TagPicker
              kind="APPLICATION"
              value={form.tags}
              options={tagOptions}
              placeholder="Where did this come from?"
              onChange={(tags) => setForm({ ...form, tags })}
            />
          </div>

          <div className="space-y-1.5 sm:col-span-2">
            <Label htmlFor="app-description">Job description</Label>
            <Textarea
              id="app-description"
              value={form.jobDescription}
              onChange={(event) => setForm({ ...form, jobDescription: event.target.value })}
              placeholder="Paste the whole posting here."
              className="min-h-28"
            />
          </div>
        </div>

        <DialogFooter>
          <Button variant="ghost" onClick={() => setOpen(false)}>
            Cancel
          </Button>
          {/* Nine controls and no asterisk anywhere: the button used to be live
              from the moment the dialog opened and answered a press with a
              toast in the far corner, away from the two fields it was about.
              Disabled says the same thing where the person is looking. */}
          <Button
            variant="default"
            onClick={submit}
            disabled={pending || !form.company.trim() || !form.roleTitle.trim() || !form.stage}
          >
            {pending && <LoaderCircleIcon className="animate-spin" />}
            Track it
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

/**
 * One of the two plain answers. A button rather than a radio: it is the same
 * hit target as the Select beside it, and there is nothing to read out of a
 * group of two whose labels are already whole sentences.
 */
function StageChoice({
  active,
  onClick,
  children,
}: {
  active: boolean;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-pressed={active}
      className={cn(
        "rounded-control border px-3 py-1.5 text-[13px] font-medium transition-colors",
        active
          ? "border-primary bg-primary-tint text-foreground"
          : "text-muted-foreground hover:text-foreground",
      )}
    >
      {children}
    </button>
  );
}