"use client"; import { useId, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ExternalLinkIcon, MailIcon, PlusIcon, Trash2Icon, UserPlusIcon } from "lucide-react"; import { toast } from "sonner"; import type { Stage, TagKind } 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 { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { MergeCompaniesDialog, type MergeCandidate } from "@/components/crm/merge-companies-dialog"; import { TagChip, type TagValue } from "@/components/tags/tag-chip"; import { TagPicker } from "@/components/tags/tag-picker"; import { SaveIndicator } from "@/components/save-indicator"; import type { SaveState } from "@/hooks/use-autosave"; import { STAGE_LABEL, STAGE_TONE } from "@/lib/stages"; import { linkHref } from "@/lib/social"; import { companyDomain } from "@/lib/company"; import { createContactAction, deleteCompanyAction, restoreRecordsAction, saveCompanyAction, stopWatchingCompanyBoardAction, } from "@/server/actions"; import { relativeDay } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; export type CompanyFields = { id: string; name: string; website: string; notes: string; }; /** * The four lists a company wears. Industry, size and location were single text * boxes until they were tags: one company is plausibly fintech AND infra, and a * typo used to be a value of its own rather than something you fix once. */ export type CompanyTags = { industry: TagValue[]; size: TagValue[]; location: TagValue[]; tags: TagValue[]; }; /** Which patch key each list writes back through. */ const TAG_PATCH_KEY = { industry: "industryIds", size: "sizeIds", location: "locationIds", tags: "tagIds", } as const; export function CompanyDetail({ company, companyTags, applications, contacts, logos, candidates, suggestedMergeId, googleAccess, watch, }: { company: CompanyFields; companyTags: CompanyTags; applications: { id: string; roleTitle: string; stage: Stage; location: string; workMode: string; salaryRange: string; /** The posting itself, when there is one. Plenty of roles have none. */ jobUrl: string; tags: TagValue[]; appliedAt: string | null; nextFollowUpAt: string | null; }[]; contacts: { id: string; name: string; title: string; email: string; relationship: string }[]; logos: boolean; /** Whether their Gmail and Calendar are connected, for the threads-and-meetings card. */ googleAccess: CorrespondenceAccess; /** Every other company on file, for folding a duplicate into this one. */ candidates: MergeCandidate[]; /** One of the candidates that looks like the same employer, if any. */ suggestedMergeId?: string; /** * The board watch on this employer, when there is one. * * There is deliberately no way to CREATE one here. Starting a watch needs a * board URL and two filter lists, which is a sentence in a conversation and a * form nobody wants; stopping one is a button, because something making noise * must never need an assistant to switch off. */ watch?: { id: string; providerLabel: string; boardUrl: string; proposed: number; enabled: boolean; lastCheckedAt: string | null; lastError: string; } | null; }) { const [values, setValues] = useState(company); const [tagSets, setTagSets] = useState(companyTags); const [stopping, setStopping] = useState(false); const zone = useViewerZone(); const [state, setState] = useState("idle"); const [adding, setAdding] = useState(false); const [person, setPerson] = useState({ name: "", title: "", email: "", relationship: "" }); const [saving, startSaving] = useTransition(); const [, startTransition] = useTransition(); const router = useRouter(); const addPerson = () => { if (!person.name.trim()) return; startSaving(async () => { try { await createContactAction({ ...person, company: values.name }); setPerson({ name: "", title: "", email: "", relationship: "" }); setAdding(false); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not save that contact."); } }); }; // Autosave on blur rather than on every keystroke: a company record is a // form you fill in, not a document you stream. const commit = (patch: Partial) => { const changed = Object.entries(patch).some( ([key, value]) => company[key as keyof CompanyFields] !== value, ); if (!changed) return; setState("saving"); startTransition(async () => { try { await saveCompanyAction(company.id, patch); setState("saved"); router.refresh(); } catch (error) { setState("idle"); toast.error(error instanceof Error ? error.message : "Could not save that."); setValues(company); } }); }; const set = (patch: Partial) => setValues((prev) => ({ ...prev, ...patch })); // Tag lists save the moment they change rather than on blur: ticking a name // in a popover has no blur a person would recognise as "done". const saveFacet = (field: keyof CompanyTags, next: TagValue[]) => { const previous = tagSets[field]; setTagSets((prev) => ({ ...prev, [field]: next })); setState("saving"); startTransition(async () => { try { await saveCompanyAction(company.id, { [TAG_PATCH_KEY[field]]: next.map((tag) => tag.id), }); setState("saved"); router.refresh(); } catch (error) { setState("idle"); toast.error(error instanceof Error ? error.message : "Could not save that."); setTagSets((prev) => ({ ...prev, [field]: previous })); } }); }; const domain = logos ? companyDomain({ name: values.name, website: values.website }) : null; const remove = () => { // It no longer refuses while applications point here, and the copy no // longer threatens: the company and its applications go to the archive // together and come back together. What this has to say is what goes with // it and what does not. const takes = applications.length > 0 ? ` ${applications.length === 1 ? "Its application goes" : `All ${applications.length} applications go`} with it, timelines and tasks included.` : ""; const stays = contacts.length > 0 ? ` The ${contacts.length === 1 ? "person" : `${contacts.length} people`} on file here stay, and lose this one company.` : ""; if (!confirm(`Delete ${company.name}?${takes}${stays} You can restore it from the archive.`)) { return; } startTransition(async () => { try { const result = await deleteCompanyAction(company.id); toast.success( `${company.name} moved to the archive${result.withIt ? `, ${result.withIt}` : ""}`, { action: { label: "Undo", onClick: () => { void restoreRecordsAction("company", [company.id]).then(() => router.refresh()); }, }, }, ); router.push("/crm/companies"); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not delete that company."); } }); }; return (
set({ name: event.target.value })} onBlur={() => commit({ name: values.name })} className="h-auto border-0 bg-transparent px-0 text-[22px] font-semibold tracking-tight shadow-none focus-visible:ring-0" />
{[...tagSets.industry, ...tagSets.location].map((tag) => tag.name).join(" · ") || "No industry set"}
{watch && !stopping && (
{watch.enabled ? "Watching" : "Paused —"} their {watch.providerLabel} board {watch.proposed > 0 ? ` · ${watch.proposed} ${watch.proposed === 1 ? "role" : "roles"} proposed` : ""} {watch.lastCheckedAt ? ` · looked ${relativeDay(watch.lastCheckedAt, zone)}` : " · not looked yet"} {watch.lastError && {watch.lastError}}
)}
{values.website && ( )}
What you know