Ctrl K
ComunidadGuías…

src/components/crm/contacts-list.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<Set<string>>(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 (
    <div>
      {selected.size > 0 && (
        <SelectionBar
          ids={ids}
          what={{ one: "person", many: "people" }}
          tagKind="CONTACT"
          onTag={tagContactsAction}
          onArchive={(picked) => 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={<PingSelected ids={ids} onDone={() => setSelected(new Set())} />}
        />
      )}

      {rows.length === 0 && selected.size > 0 && (
        <p className="text-faint mb-3 text-[12.5px]">
          None of the {selected.size} selected are on this screen. Clear the filters to see them.
        </p>
      )}

      {rows.length === 0 ? (
        <EmptyState
          icon={UsersIcon}
          title={filtered ? "Nobody matches that" : "No contacts yet"}
          description={
            filtered
              ? "Loosen the search or the filter to see everyone again."
              : "The recruiters, hiring managers and friends behind the jobs you are chasing. Add one from the job you are talking to them about, or from their company."
          }
          action={
            filtered ? (
              <Button asChild variant="outline" size="sm">
                <Link href="/crm/contacts">Show everyone</Link>
              </Button>
            ) : (
              // 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.
              <div className="flex flex-wrap justify-center gap-2">
                <Button asChild size="sm">
                  <Link href="/applications">Go to your jobs</Link>
                </Button>
                <Button asChild variant="outline" size="sm">
                  <Link href="/crm/companies">Or a company</Link>
                </Button>
              </div>
            )
          }
        />
      ) : (
        <ResizableColumns list="contacts" stored={widths}>
        <div className="bg-card shadow-card overflow-hidden rounded-xl">
          <div className="eyebrow bg-inset flex items-center gap-3 px-4 py-2">
            <Checkbox
              checked={allOn}
              onCheckedChange={toggleAll}
              aria-label="Select everyone on this screen"
            />
            {header}
          </div>

          <ul className="divide-y">
            {rows.map((contact) => (
              <li
                key={contact.id}
                className={cn(
                  "hover:bg-accent/50 relative flex items-center gap-3 px-4 transition-colors duration-150",
                  selected.has(contact.id) && "bg-accent/40",
                )}
              >
                {/* 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. */}
                <div className="relative z-[1]">
                  <Checkbox
                    checked={selected.has(contact.id)}
                    onCheckedChange={() => toggle(contact.id)}
                    aria-label={`Select ${contact.name}`}
                  />
                </div>

                {/* 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. */}
                <Link
                  href={`/crm/contacts/${contact.id}`}
                  data-nav-item
                  className="flex min-w-0 flex-1 items-center gap-2.5 py-2.5 before:absolute before:inset-0"
                >
                  <CompanyAvatar name={contact.name} domain={null} size={26} />
                  <div className="min-w-0">
                    <div className="truncate text-[13px] font-medium">{contact.name}</div>
                    <div className="text-faint flex items-center gap-1.5 truncate text-[12px]">
                      <span className="truncate">{contact.title || "No title on file"}</span>
                      {contact.tags.slice(0, 2).map((tag) => (
                        <TagChip key={tag.id} tag={tag} className="shrink-0" />
                      ))}
                      {contact.tags.length > 2 && (
                        <span
                          className="shrink-0"
                          title={contact.tags.map((tag) => tag.name).join(", ")}
                        >
                          +{contact.tags.length - 2}
                        </span>
                      )}
                    </div>
                  </div>
                </Link>

                <Cell
                  col="company"
                  className="relative hidden w-44 shrink-0 items-center gap-1.5 md:flex"
                >
                  {contact.companies.length > 0 ? (
                    <>
                      <CompanyChip
                        company={contact.companies[0]}
                        logos={logos}
                        size="sm"
                        className="min-w-0"
                      />
                      {contact.companies.length > 1 && (
                        <span
                          className="text-faint shrink-0 text-[11.5px]"
                          title={contact.companies.map((company) => company.name).join(", ")}
                        >
                          +{contact.companies.length - 1}
                        </span>
                      )}
                    </>
                  ) : (
                    <span className="text-faint text-[12px]">—</span>
                  )}
                </Cell>

                <Cell
                  col="relationship"
                  className="text-faint hidden w-32 shrink-0 truncate text-[12px] lg:block"
                >
                  {contact.relationship || "—"}
                </Cell>
                <Cell
                  col="ping"
                  className={cn(
                    "nums hidden w-24 shrink-0 text-right text-[12px] sm:block",
                    contact.pingDue ? "text-destructive font-medium" : "text-faint",
                  )}
                >
                  {contact.nextPing}
                </Cell>
                <Cell col="touch" className="nums text-faint w-24 shrink-0 text-right text-[12px]">
                  {contact.lastTouch}
                </Cell>

                <Cell
                  col="links"
                  className="relative hidden w-[60px] shrink-0 items-center justify-end gap-0.5 sm:flex"
                >
                  {contact.email && (
                    <Button
                      asChild
                      variant="ghost"
                      size="icon-sm"
                      className="text-faint hover:text-foreground"
                    >
                      <a href={`mailto:${contact.email}`} aria-label={`Email ${contact.name}`}>
                        <MailIcon />
                      </a>
                    </Button>
                  )}
                  {contact.best && (
                    <Button
                      asChild
                      variant="ghost"
                      size="icon-sm"
                      className="text-faint hover:text-foreground"
                    >
                      <a
                        href={contact.best.href}
                        target="_blank"
                        rel="noreferrer noopener"
                        aria-label={`Open ${contact.name} on ${contact.best.label}`}
                      >
                        <PlatformIcon value={contact.best.value} />
                      </a>
                    </Button>
                  )}
                </Cell>
              </li>
            ))}
          </ul>
        </div>
        </ResizableColumns>
      )}
    </div>
  );
}

/**
 * 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 (
    <div className={className} style={style}>
      {children}
    </div>
  );
}