"use client"; import Link from "next/link"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon, LoaderCircleIcon, MailIcon, MoreVerticalIcon, PlusIcon, SendIcon, SparklesIcon, Trash2Icon, UserMinusIcon, UserPlusIcon, } from "lucide-react"; import { toast } from "sonner"; import type { ActivityType, Stage } from "@prisma/client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { CorrespondenceCard, type CorrespondenceAccess } from "@/components/google/correspondence-card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { SaveIndicator } from "@/components/save-indicator"; import { TagPicker, type TagOption } from "@/components/tags/tag-picker"; import type { TagValue } from "@/components/tags/tag-chip"; import { CompanyChip } from "@/components/crm/company-chip"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { ValuePicker } from "@/components/pipeline/value-picker"; import { OfferCard, type OfferValue } from "@/components/pipeline/offer-card"; import { InterviewsPanel, type InterviewValue } from "@/components/pipeline/interviews-panel"; import { LettersPanel, type LetterRow } from "@/components/letters/letters-panel"; import { PaperThumb } from "@/components/resume/paper-thumb"; import { ResumePaper, type PaperSettings } from "@/components/resume/resume-paper"; import type { ResumeDoc } from "@/lib/resume-schema"; import { companyDomain } from "@/lib/company"; import { useAutosave } from "@/hooks/use-autosave"; import { ACTIVITY_LABEL, ACTIVITY_OPTIONS, STAGES, STAGE_LABEL, STAGE_TONE } from "@/lib/stages"; import { cn, describeAdded, relativeDay } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; import { civilDay, formatIn, shortCivilDay } from "@/lib/time"; import { DateField, parseISODate } from "@/components/ui/date-field"; import { addActivityAction, createContactAction, createTaskAction, deleteApplicationAction, restoreRecordsAction, listContactsForAttachAction, moveStageAction, setContactApplicationAction, tailorResumeForApplicationAction, toggleTaskAction, updateApplicationAction, } from "@/server/actions"; type Application = { id: string; company: string; /** The CRM record behind the name, for the "everything else with them" link. */ companyId: string | null; roleTitle: string; stage: Stage; /** 0 when nobody has said which round. Only meaningful while INTERVIEWING. */ interviewRound: number; roundLabel: string; /** Why it ended, as LOSS tags. Only shown when LOST. */ lossTags: TagValue[]; jobUrl: string; /** * What the last look at the posting found, written by check_posting_live and * by nothing else. Only GONE and UNCLEAR are shown — a green tick on * something that is working is noise. */ postingStatus?: string; postingNote?: string; postingGoneSince?: string | null; jobDescription: string; location: string; workMode: string; salaryRange: string; tags: TagValue[]; notes: string; appliedAt: string | null; nextFollowUpAt: string | null; resumeId: string | null; }; /** * Everything needed to draw the attached resume here. * * ResumePaper is a pure component — no "use client", no server-only imports — * so the document can be rendered inside this client component from plain * JSON, and the page and the slide-over can show the same thing without one * of them rendering it on the server and passing a node. */ export type ResumePreview = { id: string; name: string; doc: ResumeDoc; settings: PaperSettings; }; type Activity = { id: string; type: ActivityType; body: string; occurredAt: string }; type Contact = { id: string; name: string; title: string; email: string; linkedin: string; relationship: string; }; type Task = { id: string; title: string; done: boolean; dueAt: string | null }; export function ApplicationDetail({ application, activities, contacts, tasks, offers, interviews, letters, resumes, tagOptions, lossOptions, fieldValues, company, companies, resumePreview, logos, googleAccess, onServerChange, }: { application: Application; activities: Activity[]; contacts: Contact[]; tasks: Task[]; /** Every version of the offer, newest first. Empty until one is recorded. */ offers: OfferValue[]; interviews: InterviewValue[]; /** What has been written to this employer, newest first. */ letters: LetterRow[]; resumes: { id: string; name: string }[]; /** Every source category on file, with usage counts. */ tagOptions: TagOption[]; /** The reasons this person keeps, for the picker shown on a lost job. */ lossOptions: TagOption[]; /** Locations and work modes already in use, so the fields can offer them. */ fieldValues: { location: { value: string; count: number }[]; workMode: { value: string; count: number }[]; }; /** The employer's record, for the chip. Null only if the row is mid-repair. */ company: { id: string; name: string; website: string } | null; /** Every company on file, so changing employer picks one rather than typing. */ companies: { id: string; name: string; website: string }[]; /** The attached resume, rendered. Null when none is attached. */ resumePreview: ResumePreview | null; logos: boolean; /** Whether their Gmail and Calendar are connected, for the threads-and-meetings card. */ googleAccess: CorrespondenceAccess; /** * Re-fetch whatever server-derived props this host holds. * * On the page these come from the RSC render, so router.refresh() is enough. * In the slide-over they come from a server action held in component state, * which refresh() cannot re-run — without this the company chip, the resume * thumbnail and the source list stay on the snapshot taken when it opened. */ onServerChange?: () => void; }) { const zone = useViewerZone(); const [values, setValues] = useState({ company: application.company, roleTitle: application.roleTitle, jobUrl: application.jobUrl, jobDescription: application.jobDescription, location: application.location, workMode: application.workMode, salaryRange: application.salaryRange, tags: application.tags, lossTags: application.lossTags, interviewRound: application.interviewRound, roundLabel: application.roundLabel, notes: application.notes, // The day it fell on where the reader is, not the first ten characters of // a UTC instant — 9am in Auckland is the day before in Greenwich, and this // string is what gets written back when they touch anything else on the // form. appliedAt: application.appliedAt ? civilDay(new Date(application.appliedAt), zone) : "", nextFollowUpAt: application.nextFollowUpAt ? civilDay(new Date(application.nextFollowUpAt), zone) : "", resumeId: application.resumeId ?? "", }); const [stage, setStage] = useState(application.stage); const [, startTransition] = useTransition(); const router = useRouter(); const refresh = () => { router.refresh(); onServerChange?.(); }; // The company deliberately does NOT ride along with the autosave. // // updateApplication resolves `company` through upsertCompanyByName, which // creates the company when the name does not exist. Autosave flushes 700ms // after a pause, so typing "Stripe" and hesitating after "Str" used to mint a // company called "Str" and move the application onto it — the machine that // manufactures the duplicate employers you then have to merge. It is // committed on blur instead, when the name is a name. const { state, push } = useAutosave((next) => { const { company: _company, tags, lossTags, ...rest } = next; return updateApplicationAction(application.id, { ...rest, // Categories are rows now, so what travels is ids — and it replaces the // whole set, which is what ticking one off in the picker means. The two // sets are sent separately because they replace separately: saying why a // job ended must not clear where it came from. tagIds: tags.map((tag) => tag.id), lossTagIds: lossTags.map((tag) => tag.id), appliedAt: next.appliedAt || null, nextFollowUpAt: next.nextFollowUpAt || null, resumeId: next.resumeId || null, }); }); // Read-only echo of the facts worth seeing without scrolling. Keyed by index // rather than by text: Location and Mode are both very often "Remote". const meta = [ values.location, values.workMode, values.salaryRange, // From `values`, like every other line here, so editing the date updates // the header without a reload — and through parseISODate, because // new Date("2026-03-14") is UTC midnight and prints as the 13th anywhere // west of Greenwich. appliedLabel(values.appliedAt), values.nextFollowUpAt ? `Chase ${relativeDay(new Date(values.nextFollowUpAt), zone)}` : "", ].filter(Boolean); const set = (patch: Partial) => { const next = { ...values, ...patch }; setValues(next); push(next); }; const changeStage = (next: Stage) => { setStage(next); startTransition(async () => { const { addedTasks } = await moveStageAction(application.id, next); toast.success(`Moved to ${STAGE_LABEL[next]}`, { description: describeAdded(addedTasks) }); // The checklist puts rows on the Tasks tab, so the host has to refetch. if (addedTasks.length > 0) onServerChange?.(); }); }; return (
{/* The employer is a chip you can open, not a text field you can rename by accident. Changing which company this job belongs to is still possible, but it is a deliberate act now, through a picker that offers the companies you already have before inventing one. */}
{company ? ( ) : ( {values.company} )} { setValues((prev) => ({ ...prev, company: name })); refresh(); }} />
set({ roleTitle: event.target.value })} aria-label="Role title" className="h-auto border-0 bg-transparent px-0 text-[27px] font-semibold tracking-tight shadow-none focus-visible:ring-0 md:text-[32px]" /> {/* The facts you check on open. Read-only here and editable in Details — in the slide-over the rail is below the fold, so without this the compensation is a scroll away from itself. */} {meta.length > 0 && (
{meta.map((item, index) => ( {index > 0 && ·} {item} ))}
)}
{values.jobUrl && ( )} { if ( confirm( `Delete the ${values.company} application? Its timeline and tasks go with it, and you can restore all of it from the archive.`, ) ) { // The one record people delete most, and the only delete // in the app with no Undo — a company or a person offers // one, and this went straight to a hard reload with not so // much as a toast. It is an archive, so the way back // exists; it just was not offered where the mistake // happens. void deleteApplicationAction(application.id).then(() => { toast.success(`${values.company} moved to the archive`, { action: { label: "Undo", onClick: () => void restoreRecordsAction("application", [application.id]).then(() => { router.push(`/applications/${application.id}`); }), }, }); router.push("/applications"); }); } }} > Delete
{/* Container query, not a viewport one. This component renders in two very different boxes: the page, whose content is ~720px at a 1024px viewport once the 15rem rail is taken out, and the slide-over, which is ~688px at any viewport. `lg:` measured the window, so the panel was being given two columns on every desktop and cramming a 21rem rail into 688px. 44rem is above the panel and below the page. */}
{/* Tabs, not one column of eight cards. Everything here is about one application, but not at the same moment: you open it to see where it stands, or to read the posting before an interview, or to write down what just happened. Stacked, the posting and the notes sat three scrolls under the thing you came for. The details rail stays out of the tabs deliberately — stage, follow-up and salary are the answer to "where is this", which is true on every tab. */} Overview Timeline {activities.length > 0 && ( {activities.length} )} Interviews {interviews.length > 0 && ( {interviews.length} )} Letters {letters.length > 0 && ( {letters.length} )} Posting Notes {/* Above people, and only once there is an offer to show: at this point in a search the number IS the application, and everything else on the screen is context for it. Draws nothing at the earlier stages. */} {/* People next. They are the reason the application moves, and they were previously last in the right rail — in the panel, two scrolls below the fold. */} {resumePreview ? ( ) : ( )} {/* The threads and meetings behind this application: the company's domain and the people attached above. The timeline is what was logged; this is what actually happened. */} {/* The rounds, and what was asked in each. The questions are the point: everything else here is scaffolding for getting them written down while somebody still remembers them, which is the hour afterwards and never again. question_bank is what reads them back across every job. */} Rounds at {values.company} {/* Filed under this job, so everything written here carries the application id without anybody picking it. The whole library is on Me → Letters. */} Letters to {values.company} set({ jobDescription })} /> Notes