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 { focusRing } from "../lib/focus-ring"; import { segmentClass, rowStateClass } from "../lib/class-recipes"; import { formatTimestamp } from "../lib/format-timestamp"; 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 = { 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 headerCell = (col: SortColumn) => { const active = sort === col; const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none"; const Icon = !active ? ChevronsUpDown : order === "asc" ? ChevronUp : ChevronDown; return ( ); }; const total = data?.total ?? 0; const from = total === 0 ? 0 : offset + 1; const to = Math.min(offset + limit, total); const toolbar = (
setQText(event.target.value)} placeholder={t("objects.filter")} aria-label={t("objects.filter")} className="max-w-xs" />
{VIS.map((value) => { const active = visibility === value; return ( ); })}
{t("objects.new")}
); const columns = ( {headerCell("object_number")} {headerCell("object_name")} {t("objects.columns.visibility")} {t("objects.columns.location")} {t("objects.columns.count")} {headerCell("updated_at")} ); let body; if (isLoading) { body = ( {Array.from({ length: 8 }).map((_, i) => ( ))} ); } else if (isError) { body = ( {t("objects.loadError")} ); } else if (!data || data.items.length === 0) { body = ( {t("objects.empty")} ); } else { body = ( {data.items.map((item) => { const object = item as AdminObjectView; const selected = object.id === selectedId; return ( navigate(`/objects/${object.id}?${params}`)} className={`cursor-pointer border-b text-sm ${rowStateClass(selected)}`} > event.stopPropagation()} className={`${focusRing} rounded-sm hover:underline`} > {object.object_number} {object.object_name} {object.current_location ?? "—"} {object.number_of_objects} {formatTimestamp(object.updated_at, default_timezone, i18n.language)} ); })} ); } return (
{toolbar}
{columns} {body}
{isLoading ? t("common.loading") : t("objects.tableLabel")}
{from}–{to} {t("objects.of")} {total}
); }