feat(admin): add v2 orders list (CardList with status pill + relative time)

This commit is contained in:
Tyler
2026-06-17 14:44:24 -06:00
parent d629cfe3d5
commit 9b9643e2c7
+119
View File
@@ -0,0 +1,119 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrders } from "@/actions/orders";
import PageHeader from "@/components/admin/PageHeader";
import { CardList, CardListItem } from "@/components/admin/CardList";
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
import EmptyState from "@/components/admin/EmptyState";
import OrdersFilterButton from "@/components/admin/orders/OrdersFilterButton";
import PullToRefresh from "@/components/admin/PullToRefresh";
import { formatRelativeTime } from "@/lib/format-date";
export const dynamic = "force-dynamic";
/**
* Map the legacy `orders.status` + `pickup_complete` pair onto the
* StatusPill's `StatusKind` union. The legacy schema uses
* ('pending' | 'confirmed' | 'fulfilled' | 'canceled') and a boolean
* `pickup_complete`; the v2 design uses the simpler
* (placed | ready | picked-up | cancelled) vocabulary.
*/
function statusKind(o: { status: string; pickup_complete: boolean }): StatusKind {
if (o.status === "canceled") return "cancelled";
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
if (o.status === "confirmed") return "ready";
return "placed";
}
export default async function OrdersV2Page({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const result = await getAdminOrders();
const allOrders = result.success ? result.orders : [];
const params = await searchParams;
// The legacy RPC doesn't accept a status filter — apply it client-side.
// The shape we filter on matches the StatusPill vocabulary (placed/ready/
// picked-up/cancelled), not the legacy DB status string, so the user
// intent is the same.
const filtered = params.status
? allOrders.filter((o) => statusKind(o) === params.status)
: allOrders;
// Show at most 50 — the plan's example limit. Anything more is paginated
// (PR follow-up).
const orders = filtered.slice(0, 50);
if (orders.length === 0) {
return (
<main>
<PageHeader title="Orders" actions={<OrdersFilterButton />} />
<EmptyState
title="No orders yet"
description="When customers place orders, they'll show up here."
icon={<span aria-hidden="true">📦</span>}
/>
</main>
);
}
return (
<main>
<PageHeader
title="Orders"
subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`}
actions={<OrdersFilterButton />}
/>
<PullToRefresh>
<CardList ariaLabel="Orders">
{orders.map((order) => {
const total = Number(order.subtotal ?? 0);
return (
<CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<StatusPill status={statusKind(order)} />
<span
className="text-mono"
style={{ color: "var(--color-text-faint)" }}
>
#{order.id.slice(0, 8)}
</span>
<span
className="text-meta ml-auto"
style={{ color: "var(--color-text-muted)" }}
>
{formatRelativeTime(order.created_at)}
</span>
</div>
<div
className="text-h2"
style={{ fontWeight: 700, lineHeight: 1.3 }}
>
{order.customer_name ?? "Customer"}
</div>
<div className="flex items-center justify-between mt-2">
<span
className="text-h1 font-display"
style={{ fontWeight: 600 }}
>
${total.toFixed(2)}
</span>
<span
aria-hidden="true"
style={{ color: "var(--color-text-faint)" }}
>
</span>
</div>
</CardListItem>
);
})}
</CardList>
</PullToRefresh>
</main>
);
}