Restyle /admin/shipping to match the admin design system
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s

- 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.
This commit is contained in:
Tyler
2026-06-17 11:11:08 -06:00
parent a6df4c2dd9
commit 365609d518
2 changed files with 593 additions and 337 deletions
+29
View File
@@ -2,9 +2,25 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingOrders } from "@/actions/shipping"; import { getShippingOrders } from "@/actions/shipping";
import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel"; import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const TruckIcon = () => (
<svg
className="h-5 w-5 sm:h-6 sm:w-6 text-current"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 17h14M5 17a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM19 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z" />
<path d="M14 17V5H3v12M14 9h4l3 4v4" />
</svg>
);
export default async function ShippingFulfillmentPage() { export default async function ShippingFulfillmentPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />; if (!adminUser) return <AdminAccessDenied />;
@@ -12,9 +28,22 @@ export default async function ShippingFulfillmentPage() {
const { orders } = await getShippingOrders(); const { orders } = await getShippingOrders();
return ( return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Shipping Fulfillment"
subtitle="Create FedEx labels, print packing slips, and track outbound shipments."
icon={<TruckIcon />}
/>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
<ShippingFulfillmentPanel <ShippingFulfillmentPanel
initialOrders={orders ?? []} initialOrders={orders ?? []}
canManageOrders={adminUser.can_manage_orders ?? false} canManageOrders={adminUser.can_manage_orders ?? false}
/> />
</div>
</div>
); );
} }
+486 -259
View File
@@ -2,9 +2,19 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Truck, Search, Package, ExternalLink, Sprout, X } from "lucide-react";
import { updateShippingStatus } from "@/actions/shipping"; import { updateShippingStatus } from "@/actions/shipping";
import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates"; import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates";
import { createFedExShipment } from "@/actions/shipping/fedex-labels"; import { createFedExShipment } from "@/actions/shipping/fedex-labels";
import {
AdminButton,
AdminSearchInput,
AdminFilterTabs,
AdminBadge,
AdminEmptyState,
AdminCard,
GlassModal,
} from "./design-system";
type OrderItem = { type OrderItem = {
id: string; id: string;
@@ -40,7 +50,9 @@ const SHIPPING_STATUSES = [
{ value: "shipped", label: "Shipped" }, { value: "shipped", label: "Shipped" },
{ value: "delivered", label: "Delivered" }, { value: "delivered", label: "Delivered" },
{ value: "returned", label: "Returned" }, { value: "returned", label: "Returned" },
]; ] as const;
type ShippingStatus = (typeof SHIPPING_STATUSES)[number]["value"];
const SERVICE_LABELS: Record<FedExServiceType, string> = { const SERVICE_LABELS: Record<FedExServiceType, string> = {
FEDEX_OVERNIGHT: "FedEx Overnight", FEDEX_OVERNIGHT: "FedEx Overnight",
@@ -49,21 +61,24 @@ const SERVICE_LABELS: Record<FedExServiceType, string> = {
FEDEX_GROUND: "FedEx Ground", FEDEX_GROUND: "FedEx Ground",
}; };
const STATUS_TONE: Record<ShippingStatus | string, "warning" | "info" | "primary" | "success" | "danger" | "neutral"> = {
pending: "warning",
label_created: "info",
shipped: "primary",
delivered: "success",
returned: "danger",
};
function shortId(id: string) { function shortId(id: string) {
return id.slice(0, 8).toUpperCase(); return id.slice(0, 8).toUpperCase();
} }
function statusBadge(status: string) { function formatCurrency(amount: number) {
const map: Record<string, string> = { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
pending: "bg-yellow-100 text-yellow-800", }
label_created: "bg-blue-900/40 text-blue-800",
shipped: "bg-indigo-100 text-indigo-800", function statusLabel(status: string) {
delivered: "bg-green-900/40 text-green-800", return SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status;
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 };
} }
// ── Rate Modal ──────────────────────────────────────────────────────────────── // ── Rate Modal ────────────────────────────────────────────────────────────────
@@ -117,28 +132,23 @@ function RateModal({
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <GlassModal
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 shadow-2xl max-h-[90vh] overflow-y-auto"> title="Shipping Rates"
{/* Header */} subtitle={`${order.customer_name} · ${shortId(order.id)}`}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4 sticky top-0 bg-zinc-900 rounded-t-2xl"> onClose={onClose}
<div> maxWidth="max-w-lg"
<h2 className="text-lg font-semibold text-zinc-100">Shipping Rates</h2>
<p className="text-sm text-zinc-500">
{order.customer_name} · {shortId(order.id)}
</p>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-zinc-400 text-2xl leading-none"
> >
× <div className="space-y-4">
</button>
</div>
<div className="p-6 space-y-4">
{/* Perishable warning */} {/* Perishable warning */}
{rates !== null && rates[0]?.isPerishableOnly && ( {rates !== null && rates[0]?.isPerishableOnly && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700"> <div
className="rounded-xl border px-4 py-3 text-sm"
style={{
backgroundColor: "var(--admin-warning-soft)",
borderColor: "var(--admin-warning)",
color: "var(--admin-warning)",
}}
>
<strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available <strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
offered for temperature-sensitive items. offered for temperature-sensitive items.
@@ -146,18 +156,28 @@ function RateModal({
)} )}
{loading && ( {loading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12 gap-3">
<div className="h-6 w-6 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" /> <div
<span className="ml-3 text-zinc-500">Fetching FedEx rates...</span> className="h-5 w-5 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: "var(--admin-accent)", borderTopColor: "transparent" }}
/>
<span style={{ color: "var(--admin-text-muted)" }}>Fetching FedEx rates...</span>
</div> </div>
)} )}
{error && ( {error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400"> <div
className="rounded-xl border px-4 py-3 text-sm"
style={{
backgroundColor: "var(--admin-danger-soft)",
borderColor: "var(--admin-danger)",
color: "var(--admin-danger)",
}}
>
{error} {error}
<button <button
onClick={loadRates} onClick={loadRates}
className="ml-2 underline hover:no-underline" className="ml-2 underline hover:no-underline font-semibold"
> >
Retry Retry
</button> </button>
@@ -165,9 +185,10 @@ function RateModal({
)} )}
{rates !== null && rates.length === 0 && !loading && ( {rates !== null && rates.length === 0 && !loading && (
<div className="text-center py-8 text-zinc-500"> <AdminEmptyState
No FedEx rates available for this address. Please verify the shipping address. title="No rates available"
</div> description="No FedEx rates returned for this address. Please verify the shipping address is complete."
/>
)} )}
{rates !== null && rates.length > 0 && ( {rates !== null && rates.length > 0 && (
@@ -177,29 +198,56 @@ function RateModal({
key={rate.serviceType} key={rate.serviceType}
onClick={() => handleSelectRate(rate)} onClick={() => handleSelectRate(rate)}
disabled={creating !== null} disabled={creating !== null}
className="w-full flex items-center justify-between rounded-xl border border-zinc-800 px-4 py-3 text-left hover:border-blue-400 hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="w-full flex items-center justify-between rounded-xl border px-4 py-3 text-left transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--admin-accent)";
e.currentTarget.style.backgroundColor = "var(--admin-accent-soft)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--admin-border)";
e.currentTarget.style.backgroundColor = "var(--admin-card-bg)";
}}
> >
<div> <div>
<div className="font-semibold text-zinc-100"> <div
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType} {SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
</div> </div>
<div className="text-sm text-zinc-500"> <div
className="text-xs mt-0.5"
style={{ color: "var(--admin-text-muted)" }}
>
{rate.deliveryDateLabel {rate.deliveryDateLabel
? `Arrives ${rate.deliveryDateLabel}` ? `Arrives ${rate.deliveryDateLabel}`
: rate.deliveryDayOfWeek} : rate.deliveryDayOfWeek}
</div> </div>
{rate.isPerishableOnly && ( {rate.isPerishableOnly && (
<span className="mt-1 inline-block rounded-full bg-blue-900/40 text-blue-700 px-2 py-0.5 text-xs font-medium"> <span className="mt-1.5 inline-block">
Required for perishable <AdminBadge tone="warning">Required for perishable</AdminBadge>
</span> </span>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-bold text-zinc-100"> <div
${(rate.totalCharge / 100).toFixed(2)} className="ha-num font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(rate.totalCharge / 100)}
</div> </div>
{creating === rate.serviceType && ( {creating === rate.serviceType && (
<div className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin mt-1 mx-auto" /> <div
className="h-4 w-4 rounded-full border-2 border-t-transparent animate-spin mt-1 mx-auto"
style={{
borderColor: "var(--admin-accent)",
borderTopColor: "transparent",
}}
/>
)} )}
</div> </div>
</button> </button>
@@ -208,42 +256,309 @@ function RateModal({
)} )}
{createError && ( {createError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400"> <div
className="rounded-xl border px-4 py-3 text-sm"
style={{
backgroundColor: "var(--admin-danger-soft)",
borderColor: "var(--admin-danger)",
color: "var(--admin-danger)",
}}
>
{createError} {createError}
</div> </div>
)} )}
{result && ( {result && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-4 py-4 space-y-3"> <div
<div className="flex items-center gap-2 text-emerald-700 font-semibold"> className="rounded-xl border px-4 py-4 space-y-3"
style={{
backgroundColor: "var(--admin-primary-soft)",
borderColor: "var(--admin-primary)",
}}
>
<div
className="flex items-center gap-2 font-semibold"
style={{ color: "var(--admin-primary)" }}
>
<span className="text-lg"></span> Label Created <span className="text-lg"></span> Label Created
</div> </div>
<div className="text-sm text-emerald-600"> <div
<div>Tracking: <span className="font-mono font-medium">{result.trackingNumber}</span></div> className="text-sm"
style={{ color: "var(--admin-primary)" }}
>
<div>
Tracking:{" "}
<span
className="font-mono-ui font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{result.trackingNumber}
</span>
</div> </div>
<div className="flex gap-2"> </div>
<div className="flex flex-wrap gap-2">
{result.labelUrl && ( {result.labelUrl && (
<a <a
href={result.labelUrl} href={result.labelUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700" className="inline-flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold text-white transition-colors"
style={{ backgroundColor: "var(--admin-primary)" }}
> >
<ExternalLink className="h-4 w-4" strokeWidth={2} />
Download Label Download Label
</a> </a>
)} )}
<button <AdminButton variant="secondary" size="md" onClick={onClose}>
onClick={onClose}
className="inline-flex items-center rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Done Done
</button> </AdminButton>
</div> </div>
</div> </div>
)} )}
</div> </div>
</GlassModal>
);
}
// ── Order Card ────────────────────────────────────────────────────────────────
function OrderCard({
order,
canManageOrders,
updating,
trackingInputs,
onTrackingInputChange,
onStatusChange,
onOpenRateModal,
}: {
order: ShippingOrder;
canManageOrders: boolean;
updating: string | null;
trackingInputs: Record<string, string>;
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 (
<AdminCard noPadding className="overflow-hidden">
<div className="p-4 sm:p-5 space-y-4">
{/* Header: customer + status */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p
className="text-base sm:text-lg font-bold truncate"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</p>
{order.customer_phone && (
<p
className="mt-0.5 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_phone}
</p>
)}
<p
className="mt-0.5 font-mono-ui text-[10px] uppercase tracking-widest"
style={{ color: "var(--admin-text-muted)" }}
>
{shortId(order.id)}
</p>
</div>
<AdminBadge tone={tone} dot className="shrink-0">
{statusLabel(order.shipping_status)}
</AdminBadge>
</div>
{/* Perishable indicator */}
{hasPerishableItems && isPending && (
<div
className="flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold"
style={{
backgroundColor: "var(--admin-warning-soft)",
borderColor: "var(--admin-warning)",
color: "var(--admin-warning)",
}}
>
<Sprout className="h-3.5 w-3.5" strokeWidth={2} />
Perishable Overnight or 2-Day Air only
</div>
)}
{/* Tracking number */}
{order.tracking_number && (
<div
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm"
style={{
borderColor: "var(--admin-border-light)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div>
<span style={{ color: "var(--admin-text-muted)" }}>Tracking: </span>
<span
className="font-mono-ui font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.tracking_number}
</span>
</div>
{order.shipping_status === "label_created" && canManageOrders && (
<AdminButton
variant="primary"
size="sm"
onClick={() => onStatusChange(order.id, "shipped")}
disabled={isBusy}
isLoading={isBusy}
>
Mark Shipped
</AdminButton>
)}
</div>
)}
{/* Items */}
{shipItems.length > 0 && (
<div
className="rounded-xl p-3"
style={{ backgroundColor: "var(--admin-bg-subtle)" }}
>
<ul className="space-y-1">
{shipItems.map((item) => (
<li
key={item.id}
className="flex items-center justify-between text-sm"
>
<span style={{ color: "var(--admin-text-secondary)" }}>
{item.quantity > 1 && (
<span
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{item.quantity}×{" "}
</span>
)}
{item.products?.name ?? "Unknown Product"}
{item.products?.is_perishable && (
<span
className="ml-1.5 inline-flex items-center gap-1 text-xs"
style={{ color: "var(--admin-warning)" }}
>
<Sprout className="h-3 w-3" strokeWidth={2} />
perishable
</span>
)}
</span>
<span
className="ha-num"
style={{ color: "var(--admin-text-muted)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</span>
</li>
))}
</ul>
<div
className="mt-2 pt-2 flex justify-between"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<span
className="text-sm font-semibold"
style={{ color: "var(--admin-text-secondary)" }}
>
Subtotal
</span>
<span
className="ha-num text-sm font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(order.subtotal))}
</span>
</div> </div>
</div> </div>
)}
{/* Actions */}
{canManageOrders && hasShipItems && isPending && (
<div className="flex flex-wrap gap-2">
{order.shipping_status === "pending" && (
<AdminButton
variant="primary"
size="sm"
onClick={() => onOpenRateModal(order)}
icon={<Truck className="h-4 w-4" strokeWidth={2} />}
iconPosition="left"
>
Get FedEx Rates
</AdminButton>
)}
{(order.shipping_status === "pending" ||
order.shipping_status === "label_created") && (
<input
type="text"
placeholder="Enter tracking manually"
value={trackingInputs[order.id] ?? ""}
onChange={(e) => 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 (
<AdminButton
key={s.value}
variant={isDanger ? "danger" : "secondary"}
size="sm"
onClick={() => onStatusChange(order.id, s.value)}
disabled={isBusy}
isLoading={isBusy}
>
{`Mark ${s.label}`}
</AdminButton>
);
})}
</div>
)}
{!canManageOrders && (
<div
className="rounded-xl px-4 py-3 text-center text-sm"
style={{
backgroundColor: "var(--admin-bg-subtle)",
color: "var(--admin-text-muted)",
}}
>
No order management permission
</div>
)}
</div>
</AdminCard>
); );
} }
@@ -255,7 +570,7 @@ export default function ShippingFulfillmentPanel({
}: ShippingFulfillmentPanelProps) { }: ShippingFulfillmentPanelProps) {
const [orders, setOrders] = useState<ShippingOrder[]>(initialOrders); const [orders, setOrders] = useState<ShippingOrder[]>(initialOrders);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState<string>("");
const [updating, setUpdating] = useState<string | null>(null); const [updating, setUpdating] = useState<string | null>(null);
const [trackingInputs, setTrackingInputs] = useState<Record<string, string>>({}); const [trackingInputs, setTrackingInputs] = useState<Record<string, string>>({});
const [rateModalOrder, setRateModalOrder] = useState<ShippingOrder | null>(null); const [rateModalOrder, setRateModalOrder] = useState<ShippingOrder | null>(null);
@@ -271,13 +586,15 @@ export default function ShippingFulfillmentPanel({
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
const counts = SHIPPING_STATUSES.reduce( const counts: Record<string, number> = { all: orders.length };
(acc, s) => { for (const s of SHIPPING_STATUSES) {
acc[s.value] = orders.filter((o) => o.shipping_status === s.value).length; counts[s.value] = orders.filter((o) => o.shipping_status === s.value).length;
return acc; }
},
{} as Record<string, number> 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) { async function handleStatusChange(orderId: string, newStatus: string) {
if (!canManageOrders) return; if (!canManageOrders) return;
@@ -292,7 +609,11 @@ export default function ShippingFulfillmentPanel({
setOrders((prev) => setOrders((prev) =>
prev.map((o) => prev.map((o) =>
o.id === orderId o.id === orderId
? { ...o, shipping_status: newStatus, tracking_number: trackingNumber ?? o.tracking_number } ? {
...o,
shipping_status: newStatus,
tracking_number: trackingNumber ?? o.tracking_number,
}
: o : o
) )
); );
@@ -301,213 +622,119 @@ export default function ShippingFulfillmentPanel({
setUpdating(null); setUpdating(null);
} }
function handleTrackingInputChange(orderId: string, value: string) {
setTrackingInputs((prev) => ({ ...prev, [orderId]: value }));
}
return ( return (
<div className="min-h-screen bg-zinc-950"> <div className="space-y-4">
{/* Header */} {/* Filter + Search bar */}
<div className="bg-slate-900 px-4 py-5"> <div
<div className="mx-auto max-w-6xl"> className="rounded-2xl border p-4 shadow-[var(--admin-shadow-sm)]"
<div className="flex items-center justify-between"> style={{
<div> borderColor: "var(--admin-border)",
<h1 className="text-2xl font-bold text-white">Shipping Fulfillment</h1> backgroundColor: "var(--admin-card-bg)",
<p className="mt-1 text-sm text-slate-400"> }}
{orders.length} shipping order{orders.length !== 1 ? "s" : ""}
</p>
</div>
<div className="flex gap-3">
<Link
href="/admin/orders"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
> >
All Orders <div className="flex flex-wrap items-center gap-3">
</Link> <AdminSearchInput
<Link
href="/admin/pickup"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
Pickup
</Link>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
{/* Status filter pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setStatusFilter("")}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
!statusFilter ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
All ({orders.length})
</button>
{SHIPPING_STATUSES.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
statusFilter === s.value ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
{s.label} ({counts[s.value] ?? 0})
</button>
))}
</div>
{/* Search */}
<input
type="search"
placeholder="Search name, phone, or order #..." placeholder="Search name, phone, or order #..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => 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" onClear={() => setSearch("")}
containerClassName="min-w-[12rem]"
/> />
<div className="flex items-center gap-2 ml-auto">
<span
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{orders.length} order{orders.length !== 1 ? "s" : ""}
</span>
<Link
href="/admin/orders"
className="inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-semibold transition-colors"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-secondary)",
}}
>
<Package className="h-3.5 w-3.5" strokeWidth={2} />
All Orders
</Link>
</div>
</div>
<div className="mt-3">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v)}
tabs={tabs}
/>
</div>
</div>
{/* Orders */} {/* Orders */}
<div className="space-y-3">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400"> <AdminCard>
No shipping orders <AdminEmptyState
</div> icon={
) : ( <div
filtered.map((order) => { className="flex h-16 w-16 items-center justify-center rounded-2xl"
const { cls, label } = statusBadge(order.shipping_status); style={{ backgroundColor: "var(--admin-bg-subtle)" }}
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 (
<div key={order.id} className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId(order.id)}</p>
</div>
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold ${cls}`}>
{label}
</span>
</div>
{/* Perishable indicator */}
{hasPerishableItems && isPending && (
<div className="mt-3 rounded-lg bg-blue-900/30 border border-blue-200 px-3 py-2 text-xs text-blue-700 font-medium">
🌽 Perishable Overnight or 2-Day Air only
</div>
)}
{/* Tracking number */}
{order.tracking_number && (
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-sm flex items-center justify-between">
<div>
<span className="text-zinc-500">Tracking: </span>
<span className="font-mono font-medium text-zinc-300">{order.tracking_number}</span>
</div>
{order.shipping_status === "label_created" && (
<button
onClick={() => handleStatusChange(order.id, "shipped")}
disabled={updating === order.id}
className="text-xs rounded-lg bg-indigo-600 px-3 py-1.5 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
> >
{updating === order.id ? "..." : "Mark Shipped"} <Search
</button> className="h-8 w-8"
)} style={{ color: "var(--admin-text-muted)" }}
</div> strokeWidth={1.5}
)}
{/* Items */}
{order.order_items.filter((i) => i.fulfillment === "ship").length > 0 && (
<div className="mt-4 rounded-lg bg-slate-50 p-3">
<ul className="space-y-1">
{order.order_items
.filter((i) => i.fulfillment === "ship")
.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
{item.products?.is_perishable && (
<span className="ml-1.5 text-xs text-blue-400">🌽 perishable</span>
)}
</span>
<span className="text-zinc-500">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-bold text-zinc-100">${Number(order.subtotal).toFixed(2)}</span>
</div>
</div>
)}
{/* Actions */}
{canManageOrders && hasShipItems && isPending && (
<div className="mt-4 flex flex-wrap gap-2">
{/* Get Rates / Create Label */}
{order.shipping_status === "pending" && (
<button
onClick={() => setRateModalOrder(order)}
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Get FedEx Rates
</button>
)}
{/* Manual tracking input (fallback) */}
{(order.shipping_status === "pending" ||
order.shipping_status === "label_created") && (
<input
type="text"
placeholder="Enter tracking manually"
value={trackingInputs[order.id] ?? ""}
onChange={(e) =>
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"
/> />
)} </div>
}
{/* Status transitions */} title={
{SHIPPING_STATUSES.filter( search || statusFilter
(s) => ? "No matching shipping orders"
s.value !== order.shipping_status && : "No shipping orders"
s.value !== "label_created" // handled by create label flow }
).map((s) => ( description={
<button search || statusFilter
key={s.value} ? "Try clearing your search or status filter to see more orders."
onClick={() => handleStatusChange(order.id, s.value)} : "When customers place orders with shipping, they'll show up here for fulfillment."
disabled={updating === order.id} }
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50" action={
search || statusFilter ? (
<AdminButton
variant="secondary"
size="md"
onClick={() => {
setSearch("");
setStatusFilter("");
}}
icon={<X className="h-4 w-4" strokeWidth={2} />}
> >
{updating === order.id ? "..." : `Mark ${s.label}`} Clear filters
</button> </AdminButton>
) : undefined
}
/>
</AdminCard>
) : (
<div className="space-y-3">
{filtered.map((order) => (
<OrderCard
key={order.id}
order={order}
canManageOrders={canManageOrders}
updating={updating}
trackingInputs={trackingInputs}
onTrackingInputChange={handleTrackingInputChange}
onStatusChange={handleStatusChange}
onOpenRateModal={setRateModalOrder}
/>
))} ))}
</div> </div>
)} )}
{!canManageOrders && (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
No order management permission
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* Rate Modal */} {/* Rate Modal */}
{rateModalOrder && ( {rateModalOrder && (
<RateModal order={rateModalOrder} onClose={() => setRateModalOrder(null)} /> <RateModal order={rateModalOrder} onClose={() => setRateModalOrder(null)} />