diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index c4c82fe..24f6934 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -43,12 +43,31 @@ export function useMe() {
});
}
-export function useObjectsPage(limit: number, offset: number) {
+export type ObjectListParams = {
+ limit: number;
+ offset: number;
+ sort?: string;
+ order?: "asc" | "desc";
+ visibility?: string;
+ q?: string;
+};
+
+export function useObjectsPage(params: ObjectListParams) {
return useQuery({
- queryKey: ["objects", { limit, offset }],
+ queryKey: ["objects", params],
+ placeholderData: keepPreviousData,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/objects", {
- params: { query: { limit, offset } },
+ params: {
+ query: {
+ limit: params.limit,
+ offset: params.offset,
+ sort: params.sort,
+ order: params.order,
+ visibility: params.visibility,
+ q: params.q,
+ },
+ },
});
if (error || !data) throw new Error("failed to load objects");
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 2ed788b..b51343e 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -2,7 +2,7 @@
"app": { "name": "Collection" },
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search" },
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
- "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object" },
+ "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" } },
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" },
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index 456d269..feaaa52 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -2,7 +2,7 @@
"app": { "name": "Samling" },
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök" },
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
- "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål" },
+ "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" } },
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" },
diff --git a/web/src/objects/object-list.test.tsx b/web/src/objects/object-list.test.tsx
deleted file mode 100644
index 4801876..0000000
--- a/web/src/objects/object-list.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { beforeEach, expect, test } from "vitest";
-import { screen } from "@testing-library/react";
-import { http, HttpResponse } from "msw";
-import { Routes, Route } from "react-router-dom";
-import { server } from "../test/server";
-import { renderApp } from "../test/render";
-import { ObjectList } from "./object-list";
-import i18n from "../i18n";
-
-beforeEach(async () => {
- await i18n.changeLanguage("en");
-});
-
-function tree() {
- return (
-
- } />
- } />
-
- );
-}
-
-test("renders object rows with number, name and visibility", async () => {
- renderApp(tree(), { route: "/objects" });
- expect(await screen.findByText("LM-0042")).toBeInTheDocument();
- expect(screen.getByText("Amphora")).toBeInTheDocument();
- expect(screen.getByText("Public")).toBeInTheDocument();
-});
-
-test("shows an empty state when there are no objects", async () => {
- server.use(
- http.get("/api/admin/objects", () =>
- HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }),
- ),
- );
- renderApp(tree(), { route: "/objects" });
- expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument();
-});
-
-test("shows an error state on failure", async () => {
- server.use(
- http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })),
- );
- renderApp(tree(), { route: "/objects" });
- expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument();
-});
diff --git a/web/src/objects/object-list.tsx b/web/src/objects/object-list.tsx
deleted file mode 100644
index 95d4432..0000000
--- a/web/src/objects/object-list.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useState } from "react";
-import { Link, NavLink } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useObjectsPage } from "../api/queries";
-import { VisibilityBadge } from "./visibility-badge";
-
-const LIMIT = 50;
-
-export function ObjectList() {
- const { t } = useTranslation();
- const [offset, setOffset] = useState(0);
-
- const { data, isLoading, isError } = useObjectsPage(LIMIT, offset);
-
- const header = (
-
-
- {t("objects.new")}
-
-
- );
-
- if (isLoading) {
- return (
-
- {header}
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
- );
- }
-
- if (isError) {
- return (
-
- {header}
-
{t("objects.loadError")}
-
- );
- }
-
- if (!data || data.items.length === 0) {
- return (
-
- {header}
-
{t("objects.empty")}
-
- );
- }
-
- const from = data.total === 0 ? 0 : offset + 1;
- const to = Math.min(offset + LIMIT, data.total);
-
- return (
-
- {header}
-
- {data.items.map((object) => (
-
-
- `flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${
- isActive ? "bg-indigo-50" : "hover:bg-neutral-50"
- }`
- }
- >
-
- {object.object_number} {" "}
- {object.object_name}
-
-
-
-
- ))}
-
-
-
- {from}–{to} {t("objects.of")} {data.total}
-
-
- setOffset(Math.max(0, offset - LIMIT))}
- >
- {t("objects.prev")}
-
- = data.total}
- onClick={() => setOffset(offset + LIMIT)}
- >
- {t("objects.next")}
-
-
-
-
- );
-}
diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx
index 02fd1a7..0ad92bc 100644
--- a/web/src/objects/objects-page.test.tsx
+++ b/web/src/objects/objects-page.test.tsx
@@ -18,10 +18,17 @@ function tree() {
);
}
-test("selecting a row shows its detail in the right pane", async () => {
+test("the table is the landing view; the detail prompt is not a fixed column", async () => {
+ renderApp(tree(), { route: "/objects" });
+
+ // Table rows render full-width; no detail panel (and thus no prompt) until a row is opened.
+ expect(await screen.findByText("Amphora")).toBeInTheDocument();
+ expect(screen.queryByText(/select an object/i)).not.toBeInTheDocument();
+});
+
+test("clicking a row opens its detail in the side panel", async () => {
renderApp(tree(), { route: "/objects" });
- // Wait for both the prompt (right pane) and the list rows (left pane) to load.
- await screen.findByText(/select an object/i);
await userEvent.click(await screen.findByText("Amphora"));
+
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
});
diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx
index 10d5b67..ff28b48 100644
--- a/web/src/objects/objects-page.tsx
+++ b/web/src/objects/objects-page.tsx
@@ -1,16 +1,25 @@
-import { Outlet } from "react-router-dom";
+import { Outlet, useMatch } from "react-router-dom";
-import { ObjectList } from "./object-list";
+import { ObjectsTable } from "./objects-table";
export function ObjectsPage() {
+ // Interim layout (Phase 3 makes this a responsive pane/drawer): the table is the
+ // full-width landing view; when a `:id`/`:id/edit` child route is active we render
+ // the nested as a simple right-side panel.
+ const detailMatch = useMatch("/objects/:id");
+ const editMatch = useMatch("/objects/:id/edit");
+ const detail = detailMatch ?? editMatch;
+
return (
-
-
-
-
+
-
+
+ {detail && (
+
+
+
+ )}
);
}
diff --git a/web/src/objects/objects-table.stories.tsx b/web/src/objects/objects-table.stories.tsx
new file mode 100644
index 0000000..dc31bc9
--- /dev/null
+++ b/web/src/objects/objects-table.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, waitFor, within } from 'storybook/test'
+import { http, HttpResponse } from 'msw'
+
+import { ObjectsTable } from './objects-table'
+
+const meta = {
+ component: ObjectsTable,
+ tags: ['ai-generated'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ play: async ({ canvas }) => {
+ await expect(await canvas.findByText('Amphora')).toBeVisible()
+ await expect(canvas.getByText('LM-0042')).toBeVisible()
+ },
+}
+
+// Clicking the Name header sorts ascending and the active header reports its
+// aria-sort (the preview already provides the router; URL state lives there).
+export const Sorted: Story = {
+ play: async ({ canvas }) => {
+ await canvas.findByText('Amphora')
+ const header = canvas.getByRole('columnheader', { name: /Name/i })
+ await userEvent.click(within(header).getByRole('button'))
+ await waitFor(() => expect(header).toHaveAttribute('aria-sort', 'ascending'))
+ },
+}
+
+export const Empty: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/admin/objects', () =>
+ HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }),
+ ),
+ ],
+ },
+ },
+ play: async ({ canvas }) => {
+ await waitFor(() =>
+ expect(within(canvas.getByRole('table')).getByText(/no objects yet/i)).toBeVisible(),
+ )
+ },
+}
diff --git a/web/src/objects/objects-table.test.tsx b/web/src/objects/objects-table.test.tsx
new file mode 100644
index 0000000..1f3ea6e
--- /dev/null
+++ b/web/src/objects/objects-table.test.tsx
@@ -0,0 +1,136 @@
+import { beforeEach, expect, test } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { Route, Routes } from "react-router-dom";
+
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { objectsPage } from "../test/fixtures";
+import { ObjectsTable } from "./objects-table";
+import { ObjectDetail } from "./object-detail";
+import i18n from "../i18n";
+
+beforeEach(async () => {
+ await i18n.changeLanguage("en");
+});
+
+function tree() {
+ return (
+
+ } />
+ } />
+
+ );
+}
+
+/** Capture the query string of every objects request so assertions can inspect URL → request flow. */
+function captureRequests() {
+ const calls: URLSearchParams[] = [];
+
+ server.use(
+ http.get("/api/admin/objects", ({ request }) => {
+ calls.push(new URL(request.url).searchParams);
+
+ return HttpResponse.json(objectsPage);
+ }),
+ );
+
+ return calls;
+}
+
+function last(calls: URLSearchParams[]): URLSearchParams {
+ return calls[calls.length - 1];
+}
+
+test("renders a row per object with number, name, visibility, location and count", async () => {
+ renderApp(tree(), { route: "/objects" });
+
+ expect(await screen.findByText("Amphora")).toBeInTheDocument();
+ expect(screen.getByText("LM-0042")).toBeInTheDocument();
+ // "Public" is also a filter chip and the location is shared across fixtures;
+ // scope column assertions to the Amphora row.
+ const row = screen.getByText("Amphora").closest("tr")!;
+ expect(within(row).getByText("Public")).toBeInTheDocument();
+ expect(within(row).getByText("Vault 3")).toBeInTheDocument();
+});
+
+test("clicking a sortable header updates sort/order and aria-sort", async () => {
+ const calls = captureRequests();
+ renderApp(tree(), { route: "/objects" });
+ await screen.findByText("Amphora");
+
+ const nameHeader = screen.getByRole("columnheader", { name: /Name/i });
+ expect(nameHeader).toHaveAttribute("aria-sort", "none");
+
+ await userEvent.click(within(nameHeader).getByRole("button"));
+
+ await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "ascending"));
+ await waitFor(() => {
+ const cur = last(calls);
+ expect(cur.get("sort")).toBe("object_name");
+ expect(cur.get("order")).toBe("asc");
+ });
+
+ await userEvent.click(within(nameHeader).getByRole("button"));
+ await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "descending"));
+});
+
+test("typing in the quick filter sets q (debounced)", async () => {
+ const calls = captureRequests();
+ renderApp(tree(), { route: "/objects" });
+ await screen.findByText("Amphora");
+
+ await userEvent.type(screen.getByLabelText(/filter objects/i), "amph");
+
+ await waitFor(() => expect(last(calls).get("q")).toBe("amph"));
+});
+
+test("a visibility chip sets the visibility param", async () => {
+ const calls = captureRequests();
+ renderApp(tree(), { route: "/objects" });
+ await screen.findByText("Amphora");
+
+ await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
+
+ await waitFor(() => expect(last(calls).get("visibility")).toBe("draft"));
+});
+
+test("pagination next/prev change the offset", async () => {
+ const calls = captureRequests();
+ // total > limit so Next is enabled.
+ server.use(
+ http.get("/api/admin/objects", ({ request }) => {
+ calls.push(new URL(request.url).searchParams);
+
+ return HttpResponse.json({ ...objectsPage, total: 200 });
+ }),
+ );
+ renderApp(tree(), { route: "/objects" });
+ await screen.findByText("Amphora");
+
+ await userEvent.click(screen.getByRole("button", { name: /next/i }));
+ await waitFor(() => expect(last(calls).get("offset")).toBe("50"));
+
+ // Back to the first page: the URL drops `offset`, so the request sends offset 0.
+ await userEvent.click(screen.getByRole("button", { name: /previous/i }));
+ await waitFor(() => expect(last(calls).get("offset")).toBe("0"));
+});
+
+test("the page-size select sets the limit", async () => {
+ const calls = captureRequests();
+ renderApp(tree(), { route: "/objects" });
+ await screen.findByText("Amphora");
+
+ await userEvent.selectOptions(screen.getByLabelText(/per page/i), "100");
+
+ await waitFor(() => expect(last(calls).get("limit")).toBe("100"));
+});
+
+test("clicking a row deep-links to /objects/:id preserving the query string", async () => {
+ renderApp(tree(), { route: "/objects?sort=object_name&order=desc" });
+
+ await userEvent.click(await screen.findByText("Amphora"));
+
+ expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
+});
diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx
new file mode 100644
index 0000000..4da6892
--- /dev/null
+++ b/web/src/objects/objects-table.tsx
@@ -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 = {
+ 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 (
+
+ toggleSort(col)}
+ className="flex items-center gap-1 hover:text-neutral-900"
+ >
+ {t(COLUMN_KEYS[col])}
+
+
+
+ );
+ };
+
+ 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 (
+ setVisibility(value)}
+ className={`rounded px-2 py-1 ${active ? "bg-indigo-600 text-white" : "border"}`}
+ >
+ {value === "all" ? t("search.all") : t(`visibility.${value}`)}
+
+ );
+ })}
+
+
+ {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 ${
+ selected ? "bg-indigo-50" : "hover:bg-neutral-50"
+ }`}
+ >
+ {object.object_number}
+ {object.object_name}
+
+
+
+ {object.current_location ?? "—"}
+ {object.number_of_objects}
+ {formatUpdated(object.updated_at)}
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ {toolbar}
+
+
+
+ {t("objects.pageSize")}
+ setLimit(Number(event.target.value))}
+ aria-label={t("objects.pageSize")}
+ className="rounded border bg-white px-1 py-0.5"
+ >
+ {PAGE_SIZES.map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+ {from}–{to} {t("objects.of")} {total}
+
+
+ goToOffset(Math.max(0, offset - limit))}
+ >
+ {t("objects.prev")}
+
+ = total}
+ onClick={() => goToOffset(offset + limit)}
+ >
+ {t("objects.next")}
+
+
+
+
+ );
+}