feat(web): full-width sortable/filterable objects table with URL state (#44)

Replace the narrow ObjectList with a full-width ObjectsTable whose state
(sort/order/q/visibility/limit/offset) lives entirely in the URL via
useSearchParams. Sortable headers toggle sort+dir with aria-sort, a
debounced quick-filter and visibility chips mirror the search-panel
pattern, and a pagination footer offers prev/next + page-size select.
Rows deep-link to /objects/:id preserving the query string.

useObjectsPage now takes an ObjectListParams object (sort/order/
visibility/q) with keepPreviousData. ObjectsPage renders the table as
the full-width landing view, surfacing the nested <Outlet/> detail as a
simple right-side panel only when a :id child route is active (Phase 3
makes this responsive). object-list.tsx and its test are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:34:13 +02:00
parent 98c00d3732
commit 49f694d1fb
10 changed files with 553 additions and 169 deletions
+319
View File
@@ -0,0 +1,319 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { useConfig } from "../config/config-context";
import { VisibilityBadge } from "./visibility-badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
type AdminObjectView = components["schemas"]["AdminObjectView"];
const PAGE_SIZES = [25, 50, 100, 200];
const VIS = ["all", "draft", "internal", "public"] as const;
const DEFAULT_SORT = "object_number";
const DEFAULT_LIMIT = 50;
type SortColumn = "object_number" | "object_name" | "updated_at";
const COLUMN_KEYS: Record<SortColumn, string> = {
object_number: "objects.columns.number",
object_name: "objects.columns.name",
updated_at: "objects.columns.updated",
};
export function ObjectsTable() {
const { t, i18n } = useTranslation();
const { default_timezone } = useConfig();
const navigate = useNavigate();
const { id: selectedId } = useParams();
const [params, setParams] = useSearchParams();
const sort = params.get("sort") ?? DEFAULT_SORT;
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
const visibility = params.get("visibility") ?? "all";
const limit = Number(params.get("limit")) || DEFAULT_LIMIT;
const offset = Number(params.get("offset")) || 0;
const qParam = params.get("q") ?? "";
const [qText, setQText] = useState(qParam);
const q = useDebouncedValue(qText, 300);
// Mirror the search-panel pattern: sync the debounced quick-filter into the URL
// (setParams is router state, not component setState, so the lint rule allows it).
// Guard on the URL already matching `q` so re-renders caused by other URL updates
// (e.g. pagination changing `offset`) don't re-run this and clobber that state.
useEffect(() => {
const term = q.trim();
setParams(
(prev) => {
if ((prev.get("q") ?? "") === term) return prev;
const next = new URLSearchParams(prev);
if (term) next.set("q", term);
else next.delete("q");
next.delete("offset");
return next;
},
{ replace: true },
);
}, [q, setParams]);
const { data, isLoading, isError } = useObjectsPage({
limit,
offset,
sort,
order,
visibility: visibility === "all" ? undefined : visibility,
q: q.trim() || undefined,
});
const setParam = (mutate: (next: URLSearchParams) => void) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
mutate(next);
return next;
},
{ replace: true },
);
const toggleSort = (col: SortColumn) =>
setParam((next) => {
const curOrder = next.get("order") === "desc" ? "desc" : "asc";
const curSort = next.get("sort") ?? DEFAULT_SORT;
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
next.set("sort", col);
next.set("order", nextOrder);
next.delete("offset");
});
const setVisibility = (value: string) =>
setParam((next) => {
if (value === "all") next.delete("visibility");
else next.set("visibility", value);
next.delete("offset");
});
const setLimit = (value: number) =>
setParam((next) => {
next.set("limit", String(value));
next.delete("offset");
});
const goToOffset = (value: number) =>
setParam((next) => {
if (value <= 0) next.delete("offset");
else next.set("offset", String(value));
});
const dateFmt = new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeZone: default_timezone,
});
const formatUpdated = (iso: string) => {
const parsed = new Date(iso);
return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed);
};
const headerCell = (col: SortColumn) => {
const active = sort === col;
const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none";
const Icon = !active ? ChevronsUpDown : order === "asc" ? ChevronUp : ChevronDown;
return (
<th key={col} scope="col" aria-sort={ariaSort} className="px-3 py-2 text-left font-medium">
<button
type="button"
onClick={() => toggleSort(col)}
className="flex items-center gap-1 hover:text-neutral-900"
>
{t(COLUMN_KEYS[col])}
<Icon className="size-3.5 text-neutral-400" aria-hidden="true" />
</button>
</th>
);
};
const total = data?.total ?? 0;
const from = total === 0 ? 0 : offset + 1;
const to = Math.min(offset + limit, total);
const toolbar = (
<div className="flex flex-wrap items-center gap-2 border-b p-3">
<Input
value={qText}
onChange={(event) => setQText(event.target.value)}
placeholder={t("objects.filter")}
aria-label={t("objects.filter")}
className="max-w-xs"
/>
<div className="flex gap-1 text-xs">
{VIS.map((value) => {
const active = visibility === value;
return (
<button
key={value}
type="button"
aria-pressed={active}
onClick={() => setVisibility(value)}
className={`rounded px-2 py-1 ${active ? "bg-indigo-600 text-white" : "border"}`}
>
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button>
);
})}
</div>
<Link to="/objects/new" className={`${buttonVariants({ size: "sm" })} ml-auto`}>
{t("objects.new")}
</Link>
</div>
);
const columns = (
<thead className="border-b bg-neutral-50 text-xs text-neutral-500">
<tr>
{headerCell("object_number")}
{headerCell("object_name")}
<th scope="col" className="px-3 py-2 text-left font-medium">
{t("objects.columns.visibility")}
</th>
<th scope="col" className="px-3 py-2 text-left font-medium">
{t("objects.columns.location")}
</th>
<th scope="col" className="px-3 py-2 text-right font-medium">
{t("objects.columns.count")}
</th>
{headerCell("updated_at")}
</tr>
</thead>
);
let body;
if (isLoading) {
body = (
<tbody>
{Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
<td colSpan={6} className="px-3 py-2">
<Skeleton className="h-5 w-full" />
</td>
</tr>
))}
</tbody>
);
} else if (isError) {
body = (
<tbody>
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-red-600">
{t("objects.loadError")}
</td>
</tr>
</tbody>
);
} else if (!data || data.items.length === 0) {
body = (
<tbody>
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-neutral-500">
{t("objects.empty")}
</td>
</tr>
</tbody>
);
} else {
body = (
<tbody>
{data.items.map((item) => {
const object = item as AdminObjectView;
const selected = object.id === selectedId;
return (
<tr
key={object.id}
aria-selected={selected}
onClick={() => navigate(`/objects/${object.id}?${params}`)}
className={`cursor-pointer border-b text-sm ${
selected ? "bg-indigo-50" : "hover:bg-neutral-50"
}`}
>
<td className="px-3 py-2 text-neutral-500">{object.object_number}</td>
<td className="px-3 py-2 font-medium">{object.object_name}</td>
<td className="px-3 py-2">
<VisibilityBadge visibility={object.visibility} />
</td>
<td className="px-3 py-2 text-neutral-600">{object.current_location ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
<td className="px-3 py-2 text-neutral-600">{formatUpdated(object.updated_at)}</td>
</tr>
);
})}
</tbody>
);
}
return (
<div className="flex h-full flex-col">
{toolbar}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
{columns}
{body}
</table>
</div>
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-neutral-500">
<label className="flex items-center gap-1">
<span>{t("objects.pageSize")}</span>
<select
value={limit}
onChange={(event) => setLimit(Number(event.target.value))}
aria-label={t("objects.pageSize")}
className="rounded border bg-white px-1 py-0.5"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<span>
{from}{to} {t("objects.of")} {total}
</span>
<span className="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={offset === 0}
onClick={() => goToOffset(Math.max(0, offset - limit))}
>
{t("objects.prev")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={offset + limit >= total}
onClick={() => goToOffset(offset + limit)}
>
{t("objects.next")}
</Button>
</span>
</div>
</div>
);
}