← back to Govarbitrage

src/components/listings-table.tsx

520 lines

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import {
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnDef,
  type ColumnFiltersState,
  type SortingState,
  type VisibilityState,
} from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import type { ListingRow } from "@/lib/listings";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn, formatMoney, formatPercent, countdown } from "@/lib/utils";
import { auctionNetworkLinks, boardLinks, internationalTopLinks } from "@/lib/marketplace-links";

type ColType =
  | "text"
  | "money"
  | "score"
  | "pct"
  | "risk"
  | "dropship"
  | "countdown"
  | "source"
  | "networks"
  | "boards"
  | "intl";

const LINK_TYPES: ColType[] = ["networks", "boards", "intl"];

interface ColMeta {
  key: keyof ListingRow;
  label: string;
  type: ColType;
}

// The full column set from the spec, in display order.
const COLUMNS: ColMeta[] = [
  { key: "title", label: "Title", type: "text" },
  { key: "source", label: "Source", type: "source" },
  { key: "sourceAuctionId", label: "Auction #", type: "text" },
  { key: "category", label: "Category", type: "text" },
  { key: "manufacturer", label: "Manufacturer", type: "text" },
  { key: "model", label: "Model", type: "text" },
  { key: "condition", label: "Condition", type: "text" },
  { key: "quantity", label: "Qty", type: "score" },
  { key: "currentBid", label: "Current Bid", type: "money" },
  { key: "currentCost", label: "Current Cost", type: "money" },
  { key: "recommendedMaxBid", label: "Rec. Max Bid", type: "money" },
  { key: "retailLow", label: "Retail Low", type: "money" },
  { key: "retailAverage", label: "Retail Avg", type: "money" },
  { key: "retailHigh", label: "Retail High", type: "money" },
  { key: "usedLow", label: "Used Low", type: "money" },
  { key: "usedAverage", label: "Used Avg", type: "money" },
  { key: "usedHigh", label: "Used High", type: "money" },
  { key: "wholesale", label: "Wholesale", type: "money" },
  { key: "liquidation", label: "Liquidation", type: "money" },
  { key: "sellNow", label: "Sell Now", type: "money" },
  { key: "value7Day", label: "7-Day", type: "money" },
  { key: "value30Day", label: "30-Day", type: "money" },
  { key: "value90Day", label: "90-Day", type: "money" },
  { key: "expectedSale", label: "Expected Sale", type: "money" },
  { key: "shipping", label: "Shipping", type: "money" },
  { key: "freight", label: "Freight", type: "money" },
  { key: "repairs", label: "Repairs", type: "money" },
  { key: "marketplaceFees", label: "Mkt Fees", type: "money" },
  { key: "netProfit", label: "Net Profit", type: "money" },
  { key: "roi", label: "ROI", type: "pct" },
  { key: "id", label: "Offers on Other Networks", type: "networks" },
  { key: "id", label: "International Offers", type: "intl" },
  { key: "id", label: "Goods-Needed Boards", type: "boards" },
  { key: "risk", label: "Risk", type: "risk" },
  { key: "confidence", label: "Confidence", type: "score" },
  { key: "opportunityScore", label: "Opportunity", type: "score" },
  { key: "arbitrageScore", label: "Arbitrage", type: "score" },
  { key: "demandScore", label: "Demand", type: "score" },
  { key: "velocityScore", label: "Velocity", type: "score" },
  { key: "logisticsScore", label: "Logistics", type: "score" },
  { key: "conditionScore", label: "Cond.", type: "score" },
  { key: "competitionScore", label: "Comp.", type: "score" },
  { key: "buyerScore", label: "Buyer", type: "score" },
  { key: "dropShip", label: "Drop Ship", type: "dropship" },
  { key: "closingAt", label: "Countdown", type: "countdown" },
  { key: "researchStatus", label: "Research", type: "text" },
];

const PROFILES = [
  ["OVERALL_OPPORTUNITY", "Overall Opportunity"],
  ["BEST_ARBITRAGE", "Best Arbitrage"],
  ["QUICK_FLIP", "Quick Flip"],
  ["COLLECTOR", "Collector"],
  ["LOCAL_PICKUP", "Local Pickup"],
  ["EASY_FREIGHT", "Easy Freight"],
  ["PARTS_ONLY", "Parts Only"],
  ["HIGH_CONFIDENCE", "High Confidence"],
  ["HIGH_PROFIT", "High Profit"],
] as const;

const SOURCES = ["GOVDEALS", "GOINDUSTRY", "NETWORK_INTL", "GRAYS_AU", "GOVPLANET", "MUNICIBID", "PUBLIC_SURPLUS", "GSA_AUCTIONS", "COUNTY", "STATE_SURPLUS", "UNIVERSITY_SURPLUS"];
const CONDITIONS = ["NEW", "LIKE_NEW", "USED_GOOD", "USED_FAIR", "FOR_PARTS", "UNKNOWN"];
const RISKS = ["LOW", "MEDIUM", "HIGH"];

// Sort menu options ([value, label]; value = "columnId:dir"). SORT_VALUES lets us
// distinguish a menu sort from an ad-hoc header-click sort so the <select> never
// shows a false label (an off-menu header-click sort is session-only, not saved).
const SORT_OPTIONS: [string, string][] = [
  ["opportunityScore:desc", "Opportunity Score"],
  ["arbitrageScore:desc", "Arbitrage Score"],
  ["netProfit:desc", "Net Profit"],
  ["expectedSale:desc", "Expected Sale"],
  ["roi:desc", "ROI"],
  ["closingAt:asc", "Closing (soonest)"],
  ["currentBid:asc", "Current Bid ↑"],
  ["title:asc", "Title A→Z"],
];
const SORT_VALUES = new Set(SORT_OPTIONS.map(([v]) => v));

function scoreColor(v: number) {
  if (v >= 75) return "text-[var(--positive)]";
  if (v >= 50) return "text-[var(--warning)]";
  return "text-muted-foreground";
}

function renderCell(row: ListingRow, meta: ColMeta) {
  const v = row[meta.key];
  switch (meta.type) {
    case "money":
      return <span className="tabular-nums">{formatMoney(v as number | null)}</span>;
    case "pct":
      return (
        <span className={cn("tabular-nums", (v as number) >= 0.4 ? "text-[var(--positive)]" : "")}>
          {formatPercent(v as number)}
        </span>
      );
    case "score":
      return <span className={cn("tabular-nums font-medium", scoreColor(Number(v)))}>{v == null ? "—" : Math.round(Number(v))}</span>;
    case "risk":
      return (
        <Badge variant={v === "LOW" ? "positive" : v === "HIGH" ? "negative" : "warning"}>{String(v)}</Badge>
      );
    case "dropship":
      return <Badge variant={v === "EASY" ? "positive" : v === "INFEASIBLE" ? "negative" : "outline"}>{String(v)}</Badge>;
    case "source":
      return <Badge variant="primary">{String(v).replace(/_/g, " ")}</Badge>;
    case "countdown":
      return <span className="tabular-nums">{countdown(v as string | null)}</span>;
    case "networks":
    case "boards":
    case "intl": {
      const chips =
        meta.type === "networks"
          ? auctionNetworkLinks(row)
          : meta.type === "intl"
            ? internationalTopLinks(row)
            : boardLinks(row);
      return (
        <span className="flex gap-2 whitespace-nowrap">
          {chips.slice(0, 4).map((c) => (
            <a
              key={c.url}
              href={c.url}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
              className="text-xs text-primary hover:underline"
            >
              {c.label}↗
            </a>
          ))}
        </span>
      );
    }
    default:
      return <span className="truncate">{v == null || v === "" ? "—" : String(v)}</span>;
  }
}

export function ListingsTable() {
  const router = useRouter();
  const [rows, setRows] = useState<ListingRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState("");
  const [source, setSource] = useState("");
  const [condition, setCondition] = useState("");
  const [risk, setRisk] = useState("");
  const [profile, setProfile] = useState("OVERALL_OPPORTUNITY");
  const [sorting, setSorting] = useState<SortingState>([{ id: "opportunityScore", desc: true }]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [showColMenu, setShowColMenu] = useState(false);
  const [showFilters, setShowFilters] = useState(false);
  // Sort select — mirrors the active sorting state so both the <select> and
  // header clicks stay in sync. Persisted to localStorage.
  const [sortKey, setSortKey] = useState("opportunityScore:desc");
  // Density: 1=compact (py-1), 2=normal (py-2), 3=comfortable (py-4). Persisted.
  const [density, setDensity] = useState(2);
  // Skip the sort-sync effect's first run so it can't clobber the hydrated value.
  const firstSortSync = useRef(true);

  // Server-side search + filtering + profile selection.
  useEffect(() => {
    const ctrl = new AbortController();
    const t = setTimeout(async () => {
      setLoading(true);
      // Server narrows by the coarse facets + profile; the free-text search and
      // per-column filters run client-side across ALL fields over the loaded set.
      const qs = new URLSearchParams({ pageSize: "1000", profile });
      if (source) qs.set("source", source);
      if (condition) qs.set("condition", condition);
      if (risk) qs.set("risk", risk);
      try {
        const res = await fetch(`/api/listings?${qs}`, { signal: ctrl.signal });
        const data = await res.json();
        setRows(data.rows ?? []);
      } catch {
        /* aborted */
      } finally {
        setLoading(false);
      }
    }, 250);
    return () => {
      clearTimeout(t);
      ctrl.abort();
    };
  }, [source, condition, risk, profile]);

  // Hydrate sort + density from localStorage once on mount (SSR-safe: inside effect).
  useEffect(() => {
    const savedSort = localStorage.getItem("ga-listings-sort");
    if (savedSort) {
      setSortKey(savedSort);
      const [id, dir] = savedSort.split(":");
      setSorting([{ id, desc: dir === "desc" }]);
    }
    const savedDensity = localStorage.getItem("ga-listings-density");
    if (savedDensity) {
      const n = Number(savedDensity);
      if (n >= 1 && n <= 3) setDensity(n);
    }
  }, []);

  // Keep sortKey in sync when a header click changes sorting externally. Skip the
  // first run (mount) so it can't overwrite the value hydrate just restored, and
  // only PERSIST sorts that map to a menu option — an ad-hoc header-click sort is
  // session-only (otherwise reload would restore a value the <select> has no
  // <option> for and display a false label).
  useEffect(() => {
    if (firstSortSync.current) {
      firstSortSync.current = false;
      return;
    }
    if (sorting.length > 0) {
      const next = `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}`;
      setSortKey(next);
      if (SORT_VALUES.has(next)) localStorage.setItem("ga-listings-sort", next);
    }
  }, [sorting]);

  const columns = useMemo<ColumnDef<ListingRow>[]>(
    () =>
      COLUMNS.map((meta) => {
        const isLink = LINK_TYPES.includes(meta.type);
        return {
          ...(isLink ? { id: meta.type } : { accessorKey: meta.key }),
          header: meta.label,
          enableSorting: !isLink,
          enableColumnFilter: !isLink,
          enableGlobalFilter: !isLink,
          filterFn: "includesString",
          cell: ({ row }) => renderCell(row.original, meta),
        } as ColumnDef<ListingRow>;
      }),
    [],
  );

  const table = useReactTable({
    data: rows,
    columns,
    state: { sorting, columnVisibility, columnFilters, globalFilter: search },
    onSortingChange: setSorting,
    onColumnVisibilityChange: setColumnVisibility,
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setSearch,
    // Global search matches EVERY field of a row (text, numbers, scores).
    globalFilterFn: (row, _columnId, value) => {
      if (!value) return true;
      const q = String(value).toLowerCase();
      return Object.values(row.original as unknown as Record<string, unknown>).some((v) =>
        String(v ?? "").toLowerCase().includes(q),
      );
    },
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  const filteredCount = table.getFilteredRowModel().rows.length;

  function exportCsv() {
    const visible = COLUMNS.filter(
      (c) => !LINK_TYPES.includes(c.type) && columnVisibility[c.key] !== false,
    );
    const header = visible.map((c) => c.label).join(",");
    // Export exactly what's currently filtered + sorted on screen.
    const data = table.getFilteredRowModel().rows.map((r) => r.original);
    const lines = data.map((r) =>
      visible.map((c) => JSON.stringify(r[c.key] ?? "")).join(","),
    );
    const csv = [header, ...lines].join("\n");
    const blob = new Blob([csv], { type: "text/csv" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "govarbitrage-listings.csv";
    a.click();
    URL.revokeObjectURL(url);
  }

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <Input
          placeholder="Search all fields…"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="max-w-xs"
        />
        <Button variant={showFilters ? "default" : "outline"} size="sm" onClick={() => setShowFilters((s) => !s)}>
          Filters
        </Button>
        <Select value={profile} onChange={setProfile} options={PROFILES.map(([v, l]) => [v, l])} label="Profile" />
        <Select value={source} onChange={setSource} options={[["", "All sources"], ...SOURCES.map((s) => [s, s.replace(/_/g, " ")] as [string, string])]} />
        <Select value={condition} onChange={setCondition} options={[["", "All conditions"], ...CONDITIONS.map((c) => [c, c.replace(/_/g, " ")] as [string, string])]} />
        <Select value={risk} onChange={setRisk} options={[["", "All risk"], ...RISKS.map((r) => [r, r] as [string, string])]} />
        {/* Sort select — persisted to localStorage as "ga-listings-sort" */}
        <Select
          value={SORT_VALUES.has(sortKey) ? sortKey : "__custom__"}
          label="Sort"
          onChange={(v) => {
            if (v === "__custom__") return; // placeholder for an ad-hoc header sort
            setSortKey(v);
            localStorage.setItem("ga-listings-sort", v);
            const [id, dir] = v.split(":");
            setSorting([{ id, desc: dir === "desc" }]);
          }}
          options={
            SORT_VALUES.has(sortKey)
              ? SORT_OPTIONS
              : ([["__custom__", "Sorted by column ↕"], ...SORT_OPTIONS] as [string, string][])
          }
        />
        {/* Density slider — persisted to localStorage as "ga-listings-density" */}
        <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
          Density
          <input
            type="range"
            min={1}
            max={3}
            step={1}
            value={density}
            onChange={(e) => {
              const n = Number(e.target.value);
              setDensity(n);
              localStorage.setItem("ga-listings-density", String(n));
            }}
            className="h-1.5 w-20 cursor-pointer accent-primary"
          />
        </label>
        <div className="relative">
          <Button variant="outline" size="sm" onClick={() => setShowColMenu((s) => !s)}>
            Columns
          </Button>
          {showColMenu && (
            <div className="absolute z-20 mt-1 max-h-80 w-56 overflow-auto rounded-md border bg-card p-2 shadow-lg">
              {table.getAllLeafColumns().map((col) => (
                <label key={col.id} className="flex items-center gap-2 py-0.5 text-xs">
                  <input
                    type="checkbox"
                    checked={col.getIsVisible()}
                    onChange={col.getToggleVisibilityHandler()}
                  />
                  {COLUMNS.find((c) => c.key === col.id)?.label ?? col.id}
                </label>
              ))}
            </div>
          )}
        </div>
        <Button variant="outline" size="sm" onClick={exportCsv}>
          Export CSV
        </Button>
        {columnFilters.length > 0 && (
          <Button variant="ghost" size="sm" onClick={() => setColumnFilters([])}>
            Clear filters
          </Button>
        )}
        <span className="ml-auto text-xs text-muted-foreground">
          {loading ? "Loading…" : `${filteredCount} of ${rows.length} listings`}
        </span>
      </div>

      <div className="overflow-auto rounded-xl border">
        <table className="w-full border-collapse text-sm">
          <thead className="sticky top-0 z-10 bg-card">
            {table.getHeaderGroups().map((hg) => (
              <tr key={hg.id}>
                {hg.headers.map((header, i) => (
                  <th
                    key={header.id}
                    onClick={header.column.getToggleSortingHandler()}
                    className={cn(
                      "cursor-pointer select-none whitespace-nowrap border-b px-3 py-2 text-left text-xs font-semibold text-muted-foreground hover:text-foreground",
                      i === 0 && "sticky left-0 z-20 bg-card",
                    )}
                  >
                    {flexRender(header.column.columnDef.header, header.getContext())}
                    {{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
                  </th>
                ))}
              </tr>
            ))}
            {showFilters && (
              <tr>
                {table.getHeaderGroups()[0]?.headers.map((header, i) => (
                  <th
                    key={header.id}
                    className={cn("border-b bg-card px-2 py-1", i === 0 && "sticky left-0 z-20 bg-card")}
                  >
                    {header.column.getCanFilter() ? (
                      <input
                        value={(header.column.getFilterValue() as string) ?? ""}
                        onChange={(e) => header.column.setFilterValue(e.target.value)}
                        onClick={(e) => e.stopPropagation()}
                        placeholder="filter…"
                        className="h-6 w-full min-w-[70px] rounded border bg-transparent px-1 text-xs font-normal outline-none focus:ring-1 focus:ring-primary"
                      />
                    ) : null}
                  </th>
                ))}
              </tr>
            )}
          </thead>
          <tbody>
            {loading && (
              <tr>
                <td colSpan={COLUMNS.length} className="px-3 py-10 text-center text-muted-foreground">
                  <span className="inline-flex items-center gap-2">
                    <span className="animate-spin inline-block h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
                    Loading listings…
                  </span>
                </td>
              </tr>
            )}
            {!loading && table.getRowModel().rows.map((row) => (
              <tr
                key={row.id}
                onClick={() => router.push(`/listings/${row.original.id}`)}
                className="cursor-pointer border-b hover:bg-accent/50"
              >
                {row.getVisibleCells().map((cell, i) => (
                  <td
                    key={cell.id}
                    className={cn(
                      "whitespace-nowrap px-3",
                      density === 1 ? "py-1" : density === 3 ? "py-4" : "py-2",
                      i === 0 && "sticky left-0 z-10 max-w-[280px] truncate bg-card font-medium",
                    )}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
            {!loading && filteredCount === 0 && (
              <tr>
                <td colSpan={COLUMNS.length} className="px-3 py-10 text-center text-muted-foreground">
                  No listings match your filters.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function Select({
  value,
  onChange,
  options,
  label,
}: {
  value: string;
  onChange: (v: string) => void;
  options: [string, string][];
  label?: string;
}) {
  return (
    <select
      value={value}
      onChange={(e) => onChange(e.target.value)}
      title={label}
      className="h-8 rounded-md border bg-transparent px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-primary"
    >
      {options.map(([v, l]) => (
        <option key={v} value={v} className="bg-card">
          {l}
        </option>
      ))}
    </select>
  );
}