"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 (
);
}
/**
* 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 (
);
}