"use client"; import { useMemo, useState } from "react"; import Link from "next/link"; import { Building2Icon } 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 { TagChip, type TagValue } from "@/components/tags/tag-chip"; import { SelectionBar } from "@/components/crm/selection-bar"; import { NewCompanyDialog } from "@/components/crm/new-company-dialog"; import { ResizableColumns, useColumnStyle } from "@/components/lists/resizable-columns"; import type { StoredWidths } from "@/lib/column-widths"; import { archiveRecordsAction, tagCompaniesAction } from "@/server/actions"; import { cn } from "@/lib/utils"; export type CompanyRow = { id: string; name: string; website: string; domain: string | null; industry: TagValue[]; location: TagValue[]; lastApplied: string; applications: number; openApplications: number; contacts: number; }; /** * The companies table, its selection and its empty state. * * All three live in one client component on purpose. The obvious split — server * page renders the table, client component renders the bar — breaks on the one * path that matters most: filter down to no matches and the table subtree * unmounts, taking the selection with it, so "9 selected" vanishes the moment * you narrow the list you were selecting from. */ export function CompaniesList({ rows, filtered, exportHref, header, widths, }: { rows: CompanyRow[]; /** Anything at all is narrowing the list, so the empty state says so. */ filtered: boolean; exportHref: string; /** * The column headings, built on the server from the URL. They are * SortHeaders, which are 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; }); // Only what is on screen. Ticking the header while a filter is on must not // quietly select the rows the filter removed. 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; }); return (
{selected.size > 0 && ( 0 ? chosen : [...selected]} what={{ one: "company", many: "companies" }} tagKind="COMPANY" onTag={tagCompaniesAction} onArchive={(ids) => archiveRecordsAction("company", ids)} // 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/companies?ids=${[...selected].join(",")}`} onClear={() => setSelected(new Set())} /> )} {/* The selection can outlive the rows it was made from — tick nine, then search for something none of them match. Say so rather than showing an empty screen with a bar on it claiming nine. */} {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 everything ) : ( ) } /> ) : (
{header}
    {rows.map((company) => (
  • {/* Above the stretched link, so ticking a row never navigates. `relative` alone is not enough: the overlay is the Link's ::before, the Link comes later in the DOM, and two positioned things with no z-index are painted in DOM order — so the overlay swallowed every click on the box. */}
    toggle(company.id)} aria-label={`Select ${company.name}`} />
    {company.name}
    {company.website || "No website on file"}
    {company.lastApplied} {company.applications || "—"} {company.openApplications > 0 && ( · {company.openApplications} open )} {company.contacts || "—"}
  • ))}
)}
); } /** * 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}
); } /** * One column of tags, clipped to the width the header reserved. * * Industry and location were single strings and are lists now, so a cell that * truncated text has to wrap chips instead — and stay one line, because the row * next to it is a fixed-height link. */ function TagCell({ col, tags, className, }: { col: string; tags: TagValue[]; className?: string; }) { const style = useColumnStyle(col); return (
{tags.length === 0 ? ( — ) : ( tags.map((tag) => ) )}
); }