"use client"; import { useMemo, useState } from "react"; import Link from "next/link"; import { MailIcon, UsersIcon } from "lucide-react"; import { EmptyState } from "@/components/page-header"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { CompanyChip } from "@/components/crm/company-chip"; import { PlatformIcon } from "@/components/crm/platform-icon"; import { TagChip, type TagValue } from "@/components/tags/tag-chip"; import { SelectionBar } from "@/components/crm/selection-bar"; import { PingSelected } from "@/components/crm/ping-selected"; import { archiveRecordsAction, tagContactsAction } from "@/server/actions"; import { ResizableColumns, useColumnStyle } from "@/components/lists/resizable-columns"; import type { StoredWidths } from "@/lib/column-widths"; import { cn } from "@/lib/utils"; export type ContactRow = { id: string; name: string; title: string; relationship: string; email: string; tags: TagValue[]; companies: { id: string; name: string; website: string }[]; nextPing: string; pingDue: boolean; lastTouch: string; best: { href: string; value: string; label: string } | null; }; /** Same shape as the companies list, and the same reason it is one component. */ export function ContactsList({ rows, filtered, exportHref, logos, header, widths, }: { rows: ContactRow[]; filtered: boolean; exportHref: string; logos: boolean; /** * The column headings, built on the server from the URL. They are * SortHeaders — client components — so they read their width from the * provider below even though the page that composed them is a server * component. */ header: React.ReactNode; /** Stored column widths, already parsed and clamped by the server. */ widths: StoredWidths; }) { const [selected, setSelected] = useState>(new Set()); const chosen = useMemo( () => rows.filter((row) => selected.has(row.id)).map((row) => row.id), [rows, selected], ); const allOn = rows.length > 0 && rows.every((row) => selected.has(row.id)); const toggle = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const toggleAll = () => setSelected((prev) => { const next = new Set(prev); for (const row of rows) { if (allOn) next.delete(row.id); else next.add(row.id); } return next; }); const ids = chosen.length > 0 ? chosen : [...selected]; return (
{selected.size > 0 && ( archiveRecordsAction("contact", picked)} // Only the ids, not the screen's filters: the route intersects the // two, so exporting a selection whose rows the filter has since // excluded would hand back an empty file while the bar counts them. exportHref={`/api/export/contacts?ids=${[...selected].join(",")}`} onClear={() => setSelected(new Set())} extra={ setSelected(new Set())} />} /> )} {rows.length === 0 && selected.size > 0 && (

None of the {selected.size} selected are on this screen. Clear the filters to see them.

)} {rows.length === 0 ? ( Show everyone ) : ( // There is no add-a-person control anywhere on this screen — the // form lives on a company and on an application — so the empty // state has to point at the two places it does live, or it is a // screen that tells you what it is for and offers no way in.
) } /> ) : (
{header}
    {rows.map((contact) => (
  • {/* z-[1], not just relative: the stretched link's ::before comes later in the DOM and would otherwise paint over the checkbox and eat the click. */}
    toggle(contact.id)} aria-label={`Select ${contact.name}`} />
    {/* One link, stretched over the row by a ::before overlay, rather than an anchor wrapping the lot. The company is its own destination and an anchor inside an anchor is invalid HTML — but a second anchor to the contact would make every row two tab stops reading the same name. The overlay keeps the whole row clickable; the chip, the checkbox and the buttons are positioned, so they paint above it and stay clickable in their own right. */}
    {contact.name}
    {contact.title || "No title on file"} {contact.tags.slice(0, 2).map((tag) => ( ))} {contact.tags.length > 2 && ( tag.name).join(", ")} > +{contact.tags.length - 2} )}
    {contact.companies.length > 0 ? ( <> {contact.companies.length > 1 && ( company.name).join(", ")} > +{contact.companies.length - 1} )} ) : ( — )} {contact.relationship || "—"} {contact.lastTouch} {contact.email && ( )} {contact.best && ( )}
  • ))}
)}
); } /** * A body cell at the dragged width. * * `className` keeps the Tailwind `w-*` it replaces, so the cell still has a * width where nothing wraps it in a provider. */ function Cell({ col, className, children, }: { col: string; className?: string; children: React.ReactNode; }) { const style = useColumnStyle(col); return (
{children}
); }