← back to Trademarks Copyright
src/components/ItemsTable.tsx
319 lines
"use client";
import { useEffect, useMemo, useState } from "react";
import {
useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel,
flexRender, createColumnHelper, type SortingState, type RowSelectionState,
} from "@tanstack/react-table";
import Link from "next/link";
type Row = {
id: number;
kind: "trademark" | "copyright";
name: string;
original_owner: string | null;
nice_class: string | null;
goods_services: string | null;
status: string;
expired_date: string;
years_expired: number;
follow_up_date: string;
distinctiveness_score: number | null;
recognition_score: number | null;
domain_available: boolean | null;
competing_active_marks: number | null;
monetization_breadth: number | null;
composite_score: number | null;
};
const col = createColumnHelper<Row>();
function scoreCell(n: number | null) {
if (n === null) return <span className="text-ink/30">—</span>;
const hue = Math.round((n / 100) * 120); // red→green
return (
<span
className="inline-block rounded px-1.5 py-0.5 text-xs font-semibold text-white"
style={{ backgroundColor: `hsl(${hue} 55% 40%)` }}
title="Composite score"
>
{n.toFixed(1)}
</span>
);
}
export default function ItemsTable() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [kind, setKind] = useState("");
const [status, setStatus] = useState("");
const [sorting, setSorting] = useState<SortingState>([{ id: "composite_score", desc: true }]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [swotOpen, setSwotOpen] = useState(false);
const [swotItemId, setSwotItemId] = useState<number | null>(null);
useEffect(() => {
const url = new URL("/api/items", window.location.origin);
if (q) url.searchParams.set("q", q);
if (kind) url.searchParams.set("kind", kind);
if (status) url.searchParams.set("status", status);
setLoading(true);
fetch(url.toString())
.then((r) => r.json())
.then((j) => setRows(j.items || []))
.finally(() => setLoading(false));
}, [q, kind, status]);
const columns = useMemo(
() => [
col.display({
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => { if (el) el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
col.accessor("composite_score", {
header: "Score", cell: (c) => scoreCell(c.getValue() as number | null),
}),
col.accessor("name", {
header: "Name",
cell: (c) => (
<Link href={`/item/${c.row.original.id}`} className="font-medium text-ink hover:text-brass">
{c.getValue() as string}
</Link>
),
}),
col.accessor("kind", { header: "Type", cell: (c) => <span className="tag">{c.getValue()}</span> }),
col.accessor("status", { header: "Status", cell: (c) => <span className="tag">{c.getValue()}</span> }),
col.accessor("original_owner", { header: "Original Owner" }),
col.accessor("nice_class", { header: "Class", cell: (c) => c.getValue() ?? "—" }),
col.accessor("expired_date", {
header: "Date Expired",
cell: (c) => (c.getValue() as string)?.slice(0, 10),
}),
col.accessor("years_expired", {
header: "Yrs Exp.",
cell: (c) => <span>{c.getValue() as number}</span>,
}),
col.accessor("follow_up_date", {
header: "Follow-up (+6mo)",
cell: (c) => {
const d = (c.getValue() as string)?.slice(0, 10);
return <span className="text-ink/80" title="Revisit date — 6 months from today">{d}</span>;
},
}),
col.accessor("distinctiveness_score", { header: "Distinct." }),
col.accessor("recognition_score", { header: "Recog." }),
col.accessor("competing_active_marks", { header: "Competing" }),
col.accessor("domain_available", {
header: ".com",
cell: (c) => {
const v = c.getValue() as boolean | null;
return v === true ? <span className="text-green-700">●</span> :
v === false ? <span className="text-red-600">○</span> :
<span className="text-ink/30">?</span>;
},
}),
col.accessor("monetization_breadth", { header: "Monet." }),
],
[]
);
const table = useReactTable({
data: rows,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getRowId: (r) => String(r.id),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
enableMultiRowSelection: true,
});
const selectedIds = useMemo(
() => Object.entries(rowSelection).filter(([, v]) => v).map(([k]) => Number(k)),
[rowSelection]
);
function sendToCanvas() {
if (!selectedIds.length) return;
sessionStorage.setItem("canvasIds", JSON.stringify(selectedIds));
window.location.href = "/canvas";
}
function runSwotFor(id: number) {
setSwotItemId(id);
setSwotOpen(true);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<input
placeholder="Search name, owner, goods…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-80"
/>
<select value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="">All types</option>
<option value="trademark">Trademark</option>
<option value="copyright">Copyright</option>
</select>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="abandoned">Abandoned</option>
<option value="cancelled">Cancelled</option>
<option value="expired">Expired</option>
<option value="public_domain">Public Domain</option>
</select>
<div className="ml-auto flex items-center gap-2 text-sm">
<span className="text-ink/60">{selectedIds.length} selected</span>
<button
className="btn-ghost"
disabled={selectedIds.length !== 1}
onClick={() => selectedIds.length === 1 && runSwotFor(selectedIds[0])}
title="Run SWOT on the single selected row"
>
Run SWOT
</button>
<button
className="btn"
disabled={selectedIds.length === 0}
onClick={sendToCanvas}
>
Send {selectedIds.length || ""} → Canvas
</button>
</div>
</div>
<div className="card overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="border-b border-ink/10 bg-ink/5 text-left">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th
key={h.id}
className="select-none px-3 py-2 font-semibold text-ink/80"
onClick={h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined}
style={{ cursor: h.column.getCanSort() ? "pointer" : "default" }}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: " ▲", desc: " ▼" }[h.column.getIsSorted() as string] ?? ""}
</th>
))}
</tr>
))}
</thead>
<tbody>
{loading && (
<tr><td colSpan={columns.length} className="p-6 text-center text-ink/50">Loading…</td></tr>
)}
{!loading && table.getRowModel().rows.length === 0 && (
<tr><td colSpan={columns.length} className="p-6 text-center text-ink/50">No matches.</td></tr>
)}
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b border-ink/5 hover:bg-brass/5">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{swotOpen && swotItemId !== null && (
<SwotModal itemId={swotItemId} onClose={() => setSwotOpen(false)} />
)}
</div>
);
}
function SwotModal({ itemId, onClose }: { itemId: number; onClose: () => void }) {
const [loading, setLoading] = useState(true);
const [force, setForce] = useState(false);
const [error, setError] = useState<string | null>(null);
const [quadrants, setQuadrants] = useState<{ quadrant: string; content: string }[]>([]);
const [itemName, setItemName] = useState<string>("");
useEffect(() => {
fetch(`/api/items/${itemId}`).then((r) => r.json()).then((j) => setItemName(j.item?.name ?? ""));
}, [itemId]);
useEffect(() => {
setLoading(true);
setError(null);
fetch("/api/swot", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ itemId, force }),
})
.then(async (r) => {
const j = await r.json();
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
})
.then((j) => setQuadrants(j.quadrants || []))
.catch((e) => setError(String(e.message || e)))
.finally(() => setLoading(false));
}, [itemId, force]);
const byQ = (q: string) => quadrants.find((x) => x.quadrant === q)?.content ?? "";
const colorOf: Record<string, string> = {
strength: "bg-green-50 border-green-300",
weakness: "bg-red-50 border-red-300",
opportunity: "bg-blue-50 border-blue-300",
threat: "bg-amber-50 border-amber-300",
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="card w-full max-w-5xl max-h-[90vh] overflow-auto bg-parchment p-5" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">SWOT — {itemName}</h2>
<p className="text-xs text-ink/60">4 Claude sub-agents, one per quadrant. Cached after first run.</p>
</div>
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => setForce(true)} disabled={loading}>Re-run</button>
<button className="btn-ghost" onClick={onClose}>Close</button>
</div>
</div>
{loading && <div className="p-8 text-center text-ink/50">Running 4 agents in parallel…</div>}
{error && <div className="p-4 text-red-700">{error}</div>}
{!loading && !error && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{(["strength", "weakness", "opportunity", "threat"] as const).map((q) => (
<div key={q} className={`card border-2 p-3 ${colorOf[q]}`}>
<div className="mb-1 text-xs font-bold uppercase tracking-wide">{q}</div>
<pre className="whitespace-pre-wrap font-sans text-sm">{byQ(q) || "—"}</pre>
</div>
))}
</div>
)}
</div>
</div>
);
}