From 493ca4047fda8974a477bfcb6a27f870092b819a Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 14:49:03 -0600 Subject: [PATCH] feat(admin): add v2 order detail page with StickyActionBar + offline queue integration --- src/app/admin/v2/orders/[id]/page.tsx | 189 ++++++++++++++++++ .../admin/orders/OrderActionButtons.tsx | 132 ++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 src/app/admin/v2/orders/[id]/page.tsx create mode 100644 src/components/admin/orders/OrderActionButtons.tsx diff --git a/src/app/admin/v2/orders/[id]/page.tsx b/src/app/admin/v2/orders/[id]/page.tsx new file mode 100644 index 0000000..03bcae7 --- /dev/null +++ b/src/app/admin/v2/orders/[id]/page.tsx @@ -0,0 +1,189 @@ +import { notFound } from "next/navigation"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders"; +import { formatDate } from "@/lib/format-date"; +import PageHeader from "@/components/admin/PageHeader"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import StickyActionBar from "@/components/admin/StickyActionBar"; +import OrderActionButtons from "@/components/admin/orders/OrderActionButtons"; + +export const dynamic = "force-dynamic"; + +/** + * Map the legacy `orders.status` + `pickup_complete` pair onto the + * StatusPill's `StatusKind` union. Must match the helper in + * `orders/page.tsx` so the list and detail pages agree on the + * pill vocabulary. Kept inlined here to avoid a shared-types file + * until we know the mapping is stable. + */ +type OrderStatusKind = "placed" | "ready" | "picked-up" | "cancelled"; + +function statusKind(o: { status: string; pickup_complete: boolean }): OrderStatusKind { + 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 OrderDetailV2Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + const order = await getAdminOrderDetail(id); + if (!order) notFound(); + + const kind = statusKind(order); + const subtotal = Number(order.subtotal ?? 0); + const taxAmount = Number(order.tax_amount ?? 0); + const discountAmount = Number(order.discount_amount ?? 0); + const total = subtotal + taxAmount - discountAmount; + const itemCount = order.order_items?.length ?? 0; + + return ( +
+ } + /> +
+
+

+ {order.customer_name} +

+
+ {order.customer_phone && ( + + {order.customer_phone} + + )} + {order.customer_email && ( + + {order.customer_email} + + )} +
+
+ + {order.stops && ( +
+

+ PICKUP +

+
+ {order.stops.city}, {order.stops.state} +
+ {order.stops.date && ( +
+ {formatDate(order.stops.date)} +
+ )} +
+ )} + +
+

+ ITEMS ({itemCount}) +

+ {itemCount > 0 ? ( +
    + {order.order_items?.map((item: NonNullable[number]) => { + const unitPrice = Number(item.price ?? 0); + const lineTotal = unitPrice * item.quantity; + return ( +
  • +
    +
    + {item.products?.name ?? "Unknown product"} +
    +
    + {item.quantity} × ${unitPrice.toFixed(2)} +
    +
    +
    + ${lineTotal.toFixed(2)} +
    +
  • + ); + })} +
+ ) : ( +
+ No items +
+ )} +
+ + TOTAL + + + ${total.toFixed(2)} + +
+
+ + {order.internal_notes && ( +
+

“{order.internal_notes}”

+
+ )} +
+ + + + +
+ ); +} diff --git a/src/components/admin/orders/OrderActionButtons.tsx b/src/components/admin/orders/OrderActionButtons.tsx new file mode 100644 index 0000000..0ece2b9 --- /dev/null +++ b/src/components/admin/orders/OrderActionButtons.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { enqueueAction } from "@/lib/offline/queue"; +import { syncPending } from "@/lib/offline/sync"; +import { dispatchClientAction } from "@/actions/offline-dispatcher"; + +interface OrderActionButtonsProps { + orderId: string; + status: "placed" | "ready" | "picked-up" | "cancelled"; +} + +/** + * Sticky action bar buttons for the v2 order detail page. + * + * Maps the v2 status vocabulary to the v2 action vocabulary: + * - placed → enqueue "markOrderReady" → backend: toggle pickup_complete=false + * - ready → enqueue "markOrderPickedUp" → backend: toggle pickup_complete=true + * - picked-up → render "View Receipt" (outlined) — links to the v1 receipt page + * - cancelled → render the "View Receipt" fallback (no primary action) + * + * The action is enqueued to IndexedDB and replayed via the + * `syncPending` engine, which calls `dispatchClientAction` for each + * pending record. The StickyActionBar's `pendingSync` badge is shown + * while the queue has work, and the page is `router.refresh()`-ed on + * success so the server component re-renders with the new status. + * + * NOTE: the dispatcher (`src/actions/offline-dispatcher.ts`) currently + * returns "Unknown action" for both markOrderReady and markOrderPickedUp + * because the matching server actions don't ship in PR 3. The UI + * surface (queue, conflict, refresh) is in place — wiring the real + * handlers is a follow-up. Until then, clicks will surface in the + * OfflineBanner's "failed" count and the user sees a brief "Marking…" + * state. This matches the plan: PR 3 is structure, not the full + * happy path. + */ +export function OrderActionButtons({ orderId, status }: OrderActionButtonsProps) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [conflict, setConflict] = useState(null); + const [pendingSync, setPendingSync] = useState(false); + + async function handleAction(actionName: string) { + setPending(true); + setConflict(null); + setPendingSync(true); + try { + await enqueueAction(actionName, { orderId }); + const result = await syncPending( + (name, payload, id) => dispatchClientAction(name, payload, id), + { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }, + ); + if (result.conflicts > 0) { + setConflict("This order changed elsewhere. Refresh to see the latest."); + } else if (result.synced > 0) { + // Successful replay — the server invalidated the path; pull fresh data. + router.refresh(); + } + // result.failed: the action wasn't recognized. The OfflineBanner will + // surface the failure count; we don't inline an error here. + } finally { + setPending(false); + setPendingSync(false); + } + } + + if (conflict) { + return ( +
+ {conflict} +
+ ); + } + + if (status === "placed") { + return ( + + ); + } + + if (status === "ready") { + return ( + + ); + } + + return ( + + View Receipt + + ); +} + +export default OrderActionButtons;