"use client"; import { useState, useTransition } from "react"; import { PlusIcon, XIcon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { CompanyChip } from "@/components/crm/company-chip"; import { companyDomain } from "@/lib/company"; import { setContactCompaniesAction } from "@/server/actions"; export type CompanyRef = { id: string; name: string; website: string }; /** * Everywhere a person represents, as chips you can add to and take away. * * A contact used to point at one company, which meant choosing which of a * founder's three hats to keep. The set is written whole on every change — * same rule as an application's sources — and the server hands back what it * stored, so the chips settle on the truth rather than on what was clicked. */ export function ContactCompanies({ contactId, companies, options, logos, size = "sm", onChanged, }: { contactId: string; companies: CompanyRef[]; /** Every company on file, for the picker. */ options: CompanyRef[]; logos: boolean; size?: "sm" | "md"; onChanged?: (companies: CompanyRef[]) => void; }) { const [value, setValue] = useState(companies); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [pending, startTransition] = useTransition(); const write = (ids: string[], create?: string) => { startTransition(async () => { try { const next = await setContactCompaniesAction(contactId, ids, create); setValue(next); onChanged?.(next); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not change that."); } }); }; const add = (company: CompanyRef) => { setOpen(false); setQuery(""); if (value.some((item) => item.id === company.id)) return; write([...value.map((item) => item.id), company.id]); }; const create = (name: string) => { setOpen(false); setQuery(""); write( value.map((item) => item.id), name, ); }; const drop = (id: string) => write(value.filter((item) => item.id !== id).map((item) => item.id)); const linked = new Set(value.map((item) => item.id)); const typed = query.trim(); const exists = options.some((item) => item.name.toLowerCase() === typed.toLowerCase()); return (
{value.map((company) => ( // The remove control sits beside the chip's link rather than inside // it: a button nested in an anchor is invalid, and the chip's whole // job is to be a link to the company. ))} {/* Only when there is nothing to create either: a force-mounted item never registers in cmdk's store, so it does not count towards the filtered total and this would otherwise show above the very "Create X" row that contradicts it. */} {!typed && No company matches.} {/* The ones already linked stay in the list, marked. Filtering them out leaves typing their name showing nothing at all — no match, and no "Create" row either, since the name exists. */} {options.map((company) => ( add(company)} className="flex items-center gap-2 px-2 py-1.5 text-[13px]" > {company.name} {linked.has(company.id) && linked} ))} {typed && !exists && ( create(typed)} className="flex items-center gap-2 px-2 py-1.5 text-[13px]" > Create “{typed}” )}
); }