← 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>
);
}