"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 ( Track a job Paste the posting and Claude can tailor a resume against it later. No link is fine too — a role you're chasing through a DM, with no listing, still belongs here.
setForm({ ...form, jobUrl: event.target.value })} onKeyDown={(event) => { if (event.key === "Enter") fetchPosting(); }} placeholder="https://… — paste a posting and let it fill the form" />
setForm({ ...form, company: event.target.value })} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} placeholder="Stripe" />
setForm({ ...form, roleTitle: event.target.value })} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} placeholder="Staff Engineer, Payments" />
{/* 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. */}
setForm({ ...form, stage: "WISHLIST" })} > Not yet — just saving it setForm({ ...form, stage: "APPLIED" })} > Yes, I have applied
{/* 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" && (
setForm({ ...form, appliedAt })} ariaLabel="Applied on" placeholder="Today" />
)}
setForm({ ...form, location })} placeholder="Anywhere" ariaLabel="Location" />
setForm({ ...form, salaryRange: event.target.value })} placeholder="$180k – $230k" />
setForm({ ...form, tags })} />