From 365609d518b6eecc48a24df14123b1eab9427c6f Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 11:11:08 -0600 Subject: [PATCH] Restyle /admin/shipping to match the admin design system - page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon (matches the pattern used by /admin/orders, /admin/stops, etc.) - ShippingFulfillmentPanel.tsx: rewrite to use design system components - AdminFilterTabs for the status filter pills - AdminSearchInput for the name/phone/order search - AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards - AdminEmptyState for the no-orders view - GlassModal for the FedEx rate modal (was a raw dark modal) - Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with the cream + botanical-green + amber-accent tokens so it reads as part of the same admin shell as the rest of the app. --- src/app/admin/shipping/page.tsx | 37 +- .../admin/ShippingFulfillmentPanel.tsx | 893 +++++++++++------- 2 files changed, 593 insertions(+), 337 deletions(-) diff --git a/src/app/admin/shipping/page.tsx b/src/app/admin/shipping/page.tsx index 3f469c9..25d82f2 100644 --- a/src/app/admin/shipping/page.tsx +++ b/src/app/admin/shipping/page.tsx @@ -2,9 +2,25 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { getShippingOrders } from "@/actions/shipping"; import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; +import { PageHeader } from "@/components/admin/design-system"; export const dynamic = "force-dynamic"; +const TruckIcon = () => ( + + + + +); + export default async function ShippingFulfillmentPage() { const adminUser = await getAdminUser(); if (!adminUser) return ; @@ -12,9 +28,22 @@ export default async function ShippingFulfillmentPage() { const { orders } = await getShippingOrders(); return ( - +
+
+

Operations

+ } + /> +
+ +
+ +
+
); } diff --git a/src/components/admin/ShippingFulfillmentPanel.tsx b/src/components/admin/ShippingFulfillmentPanel.tsx index 3cafb92..a6b1b8c 100644 --- a/src/components/admin/ShippingFulfillmentPanel.tsx +++ b/src/components/admin/ShippingFulfillmentPanel.tsx @@ -2,9 +2,19 @@ import { useState } from "react"; import Link from "next/link"; +import { Truck, Search, Package, ExternalLink, Sprout, X } from "lucide-react"; import { updateShippingStatus } from "@/actions/shipping"; import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates"; import { createFedExShipment } from "@/actions/shipping/fedex-labels"; +import { + AdminButton, + AdminSearchInput, + AdminFilterTabs, + AdminBadge, + AdminEmptyState, + AdminCard, + GlassModal, +} from "./design-system"; type OrderItem = { id: string; @@ -40,7 +50,9 @@ const SHIPPING_STATUSES = [ { value: "shipped", label: "Shipped" }, { value: "delivered", label: "Delivered" }, { value: "returned", label: "Returned" }, -]; +] as const; + +type ShippingStatus = (typeof SHIPPING_STATUSES)[number]["value"]; const SERVICE_LABELS: Record = { FEDEX_OVERNIGHT: "FedEx Overnight", @@ -49,21 +61,24 @@ const SERVICE_LABELS: Record = { FEDEX_GROUND: "FedEx Ground", }; +const STATUS_TONE: Record = { + pending: "warning", + label_created: "info", + shipped: "primary", + delivered: "success", + returned: "danger", +}; + function shortId(id: string) { return id.slice(0, 8).toUpperCase(); } -function statusBadge(status: string) { - const map: Record = { - pending: "bg-yellow-100 text-yellow-800", - label_created: "bg-blue-900/40 text-blue-800", - shipped: "bg-indigo-100 text-indigo-800", - delivered: "bg-green-900/40 text-green-800", - returned: "bg-red-900/40 text-red-800", - }; - const cls = map[status] ?? "bg-zinc-950 text-zinc-300"; - const label = SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status; - return { cls, label }; +function formatCurrency(amount: number) { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); +} + +function statusLabel(status: string) { + return SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status; } // ── Rate Modal ──────────────────────────────────────────────────────────────── @@ -117,133 +132,433 @@ function RateModal({ } return ( -
-
- {/* Header */} -
-
-

Shipping Rates

-

- {order.customer_name} · {shortId(order.id)} + +

+ {/* Perishable warning */} + {rates !== null && rates[0]?.isPerishableOnly && ( +
+ Perishable shipment. Only Overnight and 2-Day Air are available + for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not + offered for temperature-sensitive items. +
+ )} + + {loading && ( +
+
+ Fetching FedEx rates... +
+ )} + + {error && ( +
+ {error} + +
+ )} + + {rates !== null && rates.length === 0 && !loading && ( + + )} + + {rates !== null && rates.length > 0 && ( +
+ {rates.map((rate) => ( + + ))} +
+ )} + + {createError && ( +
+ {createError} +
+ )} + + {result && ( +
+
+ Label Created +
+
+
+ Tracking:{" "} + + {result.trackingNumber} + +
+
+
+ {result.labelUrl && ( + + + Download Label + + )} + + Done + +
+
+ )} +
+ + ); +} + +// ── Order Card ──────────────────────────────────────────────────────────────── + +function OrderCard({ + order, + canManageOrders, + updating, + trackingInputs, + onTrackingInputChange, + onStatusChange, + onOpenRateModal, +}: { + order: ShippingOrder; + canManageOrders: boolean; + updating: string | null; + trackingInputs: Record; + onTrackingInputChange: (orderId: string, value: string) => void; + onStatusChange: (orderId: string, status: string) => void; + onOpenRateModal: (order: ShippingOrder) => void; +}) { + const tone = STATUS_TONE[order.shipping_status] ?? "neutral"; + const shipItems = order.order_items.filter((i) => i.fulfillment === "ship"); + const hasShipItems = shipItems.length > 0; + const hasPerishableItems = order.order_items.some( + (i) => i.fulfillment === "ship" && i.products?.is_perishable + ); + const isPending = + order.shipping_status === "pending" || order.shipping_status === "label_created"; + const isBusy = updating === order.id; + + return ( + +
+ {/* Header: customer + status */} +
+
+

+ {order.customer_name} +

+ {order.customer_phone && ( +

+ {order.customer_phone} +

+ )} +

+ {shortId(order.id)}

- + + {statusLabel(order.shipping_status)} +
-
- {/* Perishable warning */} - {rates !== null && rates[0]?.isPerishableOnly && ( -
- Perishable shipment. Only Overnight and 2-Day Air are available - for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not - offered for temperature-sensitive items. -
- )} + {/* Perishable indicator */} + {hasPerishableItems && isPending && ( +
+ + Perishable — Overnight or 2-Day Air only +
+ )} - {loading && ( -
-
- Fetching FedEx rates... -
- )} - - {error && ( -
- {error} - + {order.tracking_number} +
- )} + {order.shipping_status === "label_created" && canManageOrders && ( + onStatusChange(order.id, "shipped")} + disabled={isBusy} + isLoading={isBusy} + > + Mark Shipped + + )} +
+ )} - {rates !== null && rates.length === 0 && !loading && ( -
- No FedEx rates available for this address. Please verify the shipping address. -
- )} - - {rates !== null && rates.length > 0 && ( -
- {rates.map((rate) => ( - - ))} -
- )} - - {createError && ( -
- {createError} -
- )} - - {result && ( -
-
- Label Created -
-
-
Tracking: {result.trackingNumber}
-
-
- {result.labelUrl && ( - + - Download Label - - )} - -
+ {formatCurrency(Number(item.price) * item.quantity)} + + + ))} + +
+ + Subtotal + + + {formatCurrency(Number(order.subtotal))} +
- )} -
+
+ )} + + {/* Actions */} + {canManageOrders && hasShipItems && isPending && ( +
+ {order.shipping_status === "pending" && ( + onOpenRateModal(order)} + icon={} + iconPosition="left" + > + Get FedEx Rates + + )} + + {(order.shipping_status === "pending" || + order.shipping_status === "label_created") && ( + onTrackingInputChange(order.id, e.target.value)} + className="flex-1 min-w-[160px] rounded-xl border px-3 py-1.5 text-sm outline-none transition-colors" + style={{ + borderColor: "var(--admin-border)", + backgroundColor: "var(--admin-card-bg)", + color: "var(--admin-text-primary)", + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = "var(--admin-accent)"; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = "var(--admin-border)"; + }} + /> + )} + + {SHIPPING_STATUSES.filter( + (s) => s.value !== order.shipping_status && s.value !== "label_created" + ).map((s) => { + const isDanger = s.value === "returned"; + return ( + onStatusChange(order.id, s.value)} + disabled={isBusy} + isLoading={isBusy} + > + {`Mark ${s.label}`} + + ); + })} +
+ )} + + {!canManageOrders && ( +
+ No order management permission +
+ )}
-
+ ); } @@ -255,7 +570,7 @@ export default function ShippingFulfillmentPanel({ }: ShippingFulfillmentPanelProps) { const [orders, setOrders] = useState(initialOrders); const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); const [updating, setUpdating] = useState(null); const [trackingInputs, setTrackingInputs] = useState>({}); const [rateModalOrder, setRateModalOrder] = useState(null); @@ -271,13 +586,15 @@ export default function ShippingFulfillmentPanel({ return matchesSearch && matchesStatus; }); - const counts = SHIPPING_STATUSES.reduce( - (acc, s) => { - acc[s.value] = orders.filter((o) => o.shipping_status === s.value).length; - return acc; - }, - {} as Record - ); + const counts: Record = { all: orders.length }; + for (const s of SHIPPING_STATUSES) { + counts[s.value] = orders.filter((o) => o.shipping_status === s.value).length; + } + + const tabs = [ + { value: "", label: "All", count: counts.all }, + ...SHIPPING_STATUSES.map((s) => ({ value: s.value, label: s.label, count: counts[s.value] ?? 0 })), + ]; async function handleStatusChange(orderId: string, newStatus: string) { if (!canManageOrders) return; @@ -292,7 +609,11 @@ export default function ShippingFulfillmentPanel({ setOrders((prev) => prev.map((o) => o.id === orderId - ? { ...o, shipping_status: newStatus, tracking_number: trackingNumber ?? o.tracking_number } + ? { + ...o, + shipping_status: newStatus, + tracking_number: trackingNumber ?? o.tracking_number, + } : o ) ); @@ -301,212 +622,118 @@ export default function ShippingFulfillmentPanel({ setUpdating(null); } + function handleTrackingInputChange(orderId: string, value: string) { + setTrackingInputs((prev) => ({ ...prev, [orderId]: value })); + } + return ( -
- {/* Header */} -
-
-
-
-

Shipping Fulfillment

-

- {orders.length} shipping order{orders.length !== 1 ? "s" : ""} -

-
-
- - All Orders - - - Pickup - -
+
+ {/* Filter + Search bar */} +
+
+ setSearch(e.target.value)} + onClear={() => setSearch("")} + containerClassName="min-w-[12rem]" + /> +
+ + {orders.length} order{orders.length !== 1 ? "s" : ""} + + + + All Orders +
+ +
+ setStatusFilter(v)} + tabs={tabs} + /> +
-
- {/* Status filter pills */} -
- - {SHIPPING_STATUSES.map((s) => ( - + {/* Orders */} + {filtered.length === 0 ? ( + + + +
+ } + title={ + search || statusFilter + ? "No matching shipping orders" + : "No shipping orders" + } + description={ + search || statusFilter + ? "Try clearing your search or status filter to see more orders." + : "When customers place orders with shipping, they'll show up here for fulfillment." + } + action={ + search || statusFilter ? ( + { + setSearch(""); + setStatusFilter(""); + }} + icon={} + > + Clear filters + + ) : undefined + } + /> + + ) : ( +
+ {filtered.map((order) => ( + ))}
- - {/* Search */} - setSearch(e.target.value)} - className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900" - /> - - {/* Orders */} -
- {filtered.length === 0 ? ( -
- No shipping orders -
- ) : ( - filtered.map((order) => { - const { cls, label } = statusBadge(order.shipping_status); - const hasShipItems = order.order_items.some((i) => i.fulfillment === "ship"); - const hasPerishableItems = order.order_items.some( - (i) => i.fulfillment === "ship" && i.products?.is_perishable - ); - const isPending = order.shipping_status === "pending" || order.shipping_status === "label_created"; - - return ( -
- {/* Header */} -
-
-

{order.customer_name}

- {order.customer_phone && ( -

{order.customer_phone}

- )} -

{shortId(order.id)}

-
- - {label} - -
- - {/* Perishable indicator */} - {hasPerishableItems && isPending && ( -
- 🌽 Perishable — Overnight or 2-Day Air only -
- )} - - {/* Tracking number */} - {order.tracking_number && ( -
-
- Tracking: - {order.tracking_number} -
- {order.shipping_status === "label_created" && ( - - )} -
- )} - - {/* Items */} - {order.order_items.filter((i) => i.fulfillment === "ship").length > 0 && ( -
-
    - {order.order_items - .filter((i) => i.fulfillment === "ship") - .map((item) => ( -
  • - - {item.quantity > 1 && ( - {item.quantity}× - )} - {item.products?.name ?? "Unknown Product"} - {item.products?.is_perishable && ( - 🌽 perishable - )} - - - ${(Number(item.price) * item.quantity).toFixed(2)} - -
  • - ))} -
-
- Subtotal - ${Number(order.subtotal).toFixed(2)} -
-
- )} - - {/* Actions */} - {canManageOrders && hasShipItems && isPending && ( -
- {/* Get Rates / Create Label */} - {order.shipping_status === "pending" && ( - - )} - - {/* Manual tracking input (fallback) */} - {(order.shipping_status === "pending" || - order.shipping_status === "label_created") && ( - - setTrackingInputs((prev) => ({ ...prev, [order.id]: e.target.value })) - } - className="flex-1 min-w-[160px] rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900" - /> - )} - - {/* Status transitions */} - {SHIPPING_STATUSES.filter( - (s) => - s.value !== order.shipping_status && - s.value !== "label_created" // handled by create label flow - ).map((s) => ( - - ))} -
- )} - - {!canManageOrders && ( -
- No order management permission -
- )} -
- ); - }) - )} -
-
+ )} {/* Rate Modal */} {rateModalOrder && ( @@ -514,4 +741,4 @@ export default function ShippingFulfillmentPanel({ )}
); -} \ No newline at end of file +}