import type { ApplicationStatus } from "@reactive-resume/schema/applications/data"; import type { Application } from "../types"; import type { FileAttachment } from "./file-attachment-field"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { SparkleIcon, XIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; import { STAGES } from "@reactive-resume/schema/applications/data"; import { Button } from "@reactive-resume/ui/components/button"; import { Input } from "@reactive-resume/ui/components/input"; import { Label } from "@reactive-resume/ui/components/label"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from "@reactive-resume/ui/components/sheet"; import { Textarea } from "@reactive-resume/ui/components/textarea"; import { Combobox } from "@/components/ui/combobox"; import { orpc } from "@/libs/orpc/client"; import { applicationsListQueryKey } from "../queries"; import { FileAttachmentField } from "./file-attachment-field"; // Preset source suggestions surfaced via a ; the field itself stays free-text. const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"]; const todayInputValue = () => new Date().toISOString().slice(0, 10); const emptyForm = () => ({ company: "", role: "", location: "", salary: "", source: "", status: "saved" as ApplicationStatus, resumeId: "", tags: [] as string[], sourceUrl: "", stageEnteredAt: todayInputValue(), jobDescription: "", followUpAt: "", followUpNote: "", notes: "", resumeFile: null as FileAttachment | null, coverLetter: null as FileAttachment | null, }); type FormState = ReturnType; const toAttachment = (url: string | null, name: string | null): FileAttachment | null => url ? { url, name: name ?? url } : null; function toForm(app: Application): FormState { return { company: app.company, role: app.role, location: app.location ?? "", salary: app.salary ?? "", source: app.source ?? "", status: app.status, resumeId: app.resumeId ?? "", tags: app.tags, sourceUrl: app.sourceUrl ?? "", stageEnteredAt: "", jobDescription: app.jobDescription ?? "", followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "", followUpNote: app.followUpNote ?? "", notes: app.notes ?? "", resumeFile: toAttachment(app.resumeFileUrl, app.resumeFileName), coverLetter: toAttachment(app.coverLetterUrl, app.coverLetterName), }; } type Props = { open: boolean; onOpenChange: (open: boolean) => void; // When provided, the sheet edits this application instead of creating a new one. application?: Application | null; }; export function ApplicationFormSheet({ open, onOpenChange, application }: Props) { const queryClient = useQueryClient(); const isEditing = !!application; const [form, setForm] = useState(() => (application ? toForm(application) : emptyForm())); // Re-sync the form when the sheet's target changes (a different app, or create ↔ edit). const [syncedId, setSyncedId] = useState(application?.id ?? null); if ((application?.id ?? null) !== syncedId) { setSyncedId(application?.id ?? null); setForm(application ? toForm(application) : emptyForm()); } const { data: resumes } = useQuery(orpc.resume.list.queryOptions()); const resumeOptions = (resumes ?? []).map((resume) => ({ value: resume.id, label: resume.name })); const { data: allTags } = useQuery(orpc.applications.tags.queryOptions()); const set = (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }); void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() }); void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() }); if (application) { void queryClient.invalidateQueries({ queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }), }); } }; const create = useMutation( orpc.applications.create.mutationOptions({ onSuccess: () => { invalidate(); toast.success(t`Application added to your pipeline.`); setForm(emptyForm()); onOpenChange(false); }, onError: () => toast.error(t`Couldn't add the application. Please try again.`), }), ); const update = useMutation( orpc.applications.update.mutationOptions({ onSuccess: () => { invalidate(); toast.success(t`Application updated.`); onOpenChange(false); }, onError: () => toast.error(t`Couldn't save your changes. Please try again.`), }), ); const autofill = useMutation( orpc.applications.ai.autofill.mutationOptions({ onSuccess: (result) => { setForm((prev) => ({ ...prev, company: result.company || prev.company, role: result.role || prev.role, location: result.location || prev.location, salary: result.salary || prev.salary, jobDescription: result.jobDescription || prev.jobDescription, })); toast.success(t`Filled in what we could from the posting.`); }, onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`), }), ); const pending = create.isPending || update.isPending; const submit = () => { if (!form.company.trim() || !form.role.trim()) return; const payload = { company: form.company.trim(), role: form.role.trim(), status: form.status, location: form.location.trim() || null, salary: form.salary.trim() || null, source: form.source.trim() || null, resumeId: form.resumeId || null, tags: form.tags, sourceUrl: form.sourceUrl.trim() || null, jobDescription: form.jobDescription.trim() || null, notes: form.notes.trim() || null, followUpNote: form.followUpNote.trim() || null, followUpAt: form.followUpAt ? new Date(form.followUpAt) : null, resumeFileUrl: form.resumeFile?.url ?? null, resumeFileName: form.resumeFile?.name ?? null, coverLetterUrl: form.coverLetter?.url ?? null, coverLetterName: form.coverLetter?.name ?? null, }; if (application) update.mutate({ id: application.id, ...payload }); else create.mutate({ ...payload, stageEnteredAt: form.stageEnteredAt || undefined }); }; return ( {isEditing ? Edit application : Add application} {isEditing ? ( Update this application's details. ) : ( Track a job you're applying to and link the resume you sent. )}
{/* AI job-posting autofill: extracts the fields below from a posting URL. */} {!isEditing && (
set("sourceUrl", event.target.value)} />

Let AI read the posting and fill the fields below.

)} set("company", event.target.value)} /> set("role", event.target.value)} />
set("location", event.target.value)} /> set("salary", event.target.value)} />
set("source", event.target.value)} /> {SOURCE_OPTIONS.map((option) => ( ({ value: s.value, label: s.label }))} onValueChange={(value) => value && set("status", value)} />
{!isEditing && ( set("stageEnteredAt", event.target.value)} /> )} {/* Resume: link a live Reactive Resume (unlocks AI) or upload the exact PDF you sent. */}
set("resumeId", value ?? "")} /> set("resumeFile", value)} />

Linking a Reactive Resume enables AI match scoring and tailoring.

set("coverLetter", value)} /> set("tags", tags)} />
set("followUpAt", event.target.value)} /> set("followUpNote", event.target.value)} />