# Admin Table A searchable, paginated table of orders with inline actions ("ship", "refund") per row. Admin-only: every route call and every rpc call runs through `isUserAdminOrThrow` from the shared admin helpers. Search and status filter run server-side via an `@rpc` that returns a page of results plus the total count. The recipe composes three patterns already shown elsewhere: admin role gating (`app/shared/services/admin.ts` from `elements man recipes admin-roles`), debounced server-driven search (from `elements man recipes search-filter`), and offset-based pagination with page numbers. The inline actions are thin `@rpc` calls that mutate one row and return the refreshed page so the table re-renders. ## Migration ```bash elements create migration 'add orders' -tables=orders ``` `app/migrations/-add-orders.migration.sql`: ```sql -- add orders -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create type orderStatus as enum ('pending', 'shipped', 'refunded'); create table orders ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), customerName text not null, status orderStatus not null default 'pending', amountCents integer not null ); create index ordersStatusIdx on orders (status); create index ordersCustomerNameIdx on orders (lower(customerName)); create index ordersCreatedAtIdx on orders (createdAt desc); create trigger ordersTouchUpdatedAt before update on orders for each row execute function touchUpdatedAt(); ``` `orderStatus` is a Postgres enum that constrains the status column to the three valid values. The three indexes back the three lookups the admin page does: filter by status, search by customer name (lowercased), and sort by recency. ## Page setup ```bash elements create page admin-orders ``` The `Order` interface, the filter shape, the search rpc, and the status-update rpc are used only by this page, so they live in the page's own `services.ts`. The admin gate comes from `app/shared/services/admin.ts` (see `elements man recipes admin-roles` for that file). The route runs the initial search and passes the first page to the template; the template debounces filter changes and refreshes via the same rpc. `app/pages/admin-orders/services.ts`: ```ts import { sql } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; export type OrderStatus = "pending" | "shipped" | "refunded"; export interface Order { id: string; createdAt: Date; customerName: string; status: OrderStatus; amountCents: number; } export interface OrderQuery { text: string; status: OrderStatus | "all"; page: number; } export interface OrderPage { orders: Order[]; total: number; page: number; pageCount: number; } const PAGE_SIZE = 25; /** @rpc */ export function searchOrders(query: OrderQuery): OrderPage { isUserAdminOrThrow(); let pattern = `%${query.text.trim()}%`; let page = Math.max(1, query.page); let offset = (page - 1) * PAGE_SIZE; let total: number; let rows: Order[]; if (query.status === "all") { total = sql<{ count: number }>( `select count(*)::int as count from orders where customerName ilike ${pattern}`, ).firstOrThrow().count; rows = sql( `select id, createdAt, customerName, status, amountCents from orders where customerName ilike ${pattern} order by createdAt desc limit ${PAGE_SIZE} offset ${offset}`, ).all(); } else { total = sql<{ count: number }>( `select count(*)::int as count from orders where customerName ilike ${pattern} and status = ${query.status}`, ).firstOrThrow().count; rows = sql( `select id, createdAt, customerName, status, amountCents from orders where customerName ilike ${pattern} and status = ${query.status} order by createdAt desc limit ${PAGE_SIZE} offset ${offset}`, ).all(); } let pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); return { orders: rows, total, page, pageCount }; } /** @rpc */ export function setOrderStatus(orderId: string, status: OrderStatus, query: OrderQuery): OrderPage { isUserAdminOrThrow(); sql(`update orders set status = ${status} where id = ${orderId}`); return searchOrders(query); } ``` `searchOrders` branches on whether the status filter is `"all"` or a specific value, so the SQL stays explicit and the indexes match the query shape. Both branches return the page rows plus the total count so the template can render page numbers. `setOrderStatus` updates one row and re-runs the search with the current query, returning the refreshed page. The template binds the returned page to its state, so the table re-renders with the new status immediately. The admin check at the top blocks a non-admin who knows the rpc name from calling it directly over the network; the route guard below blocks page rendering for non-admins. `app/pages/admin-orders/index.ts`: ```ts import adminOrders from "./template"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import { searchOrders, OrderQuery } from "./services"; export default function route(req, res) { isUserAdminOrThrow(); let initialQuery: OrderQuery = { text: "", status: "all", page: 1 }; let initial = searchOrders(initialQuery); return new adminOrders({ initial }); } ``` The route gates on admin status, runs an initial search with the default query, and passes the first page into the template. The first paint shows orders; no fetching spinner on mount. `app/pages/admin-orders/template.html`: ```html import "./style.css"; import { Order, OrderQuery, OrderStatus, OrderPage, searchOrders, setOrderStatus, } from "./services"; let debounceTimer: ReturnType | null = null; function refresh(query: OrderQuery, results: { value: OrderPage }) { results.value = searchOrders({ text: query.text, status: query.status, page: query.page, }); } function onChange(query: OrderQuery, results: { value: OrderPage }) { if (debounceTimer !== null) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { query.page = 1; refresh(query, results); }, 300); } function onGoToPage(target: number, query: OrderQuery, results: { value: OrderPage }) { if (target < 1) { return; } if (target > results.value.pageCount) { return; } query.page = target; refresh(query, results); } function onAction(orderId: string, status: OrderStatus, query: OrderQuery, results: { value: OrderPage }) { results.value = setOrderStatus(orderId, status, { text: query.text, status: query.status, page: query.page, }); }

orders

onChange(query, results)}>
customer status amount placed actions
{o.customerName} {o.status} ${(o.amountCents / 100).toFixed(2)} {o.createdAt.toLocaleDateString()}
no orders match.
``` The text input and the status `