Restyle /admin/shipping to match the admin design system
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
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:
@@ -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 (
|
||||||
<ShippingFulfillmentPanel
|
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
initialOrders={orders ?? []}
|
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||||
canManageOrders={adminUser.can_manage_orders ?? false}
|
<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
|
||||||
|
initialOrders={orders ?? []}
|
||||||
|
canManageOrders={adminUser.can_manage_orders ?? false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,133 +132,433 @@ 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">
|
<div className="space-y-4">
|
||||||
{order.customer_name} · {shortId(order.id)}
|
{/* Perishable warning */}
|
||||||
|
{rates !== null && rates[0]?.isPerishableOnly && (
|
||||||
|
<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
|
||||||
|
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
|
||||||
|
offered for temperature-sensitive items.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12 gap-3">
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<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}
|
||||||
|
<button
|
||||||
|
onClick={loadRates}
|
||||||
|
className="ml-2 underline hover:no-underline font-semibold"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rates !== null && rates.length === 0 && !loading && (
|
||||||
|
<AdminEmptyState
|
||||||
|
title="No rates available"
|
||||||
|
description="No FedEx rates returned for this address. Please verify the shipping address is complete."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rates !== null && rates.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rates.map((rate) => (
|
||||||
|
<button
|
||||||
|
key={rate.serviceType}
|
||||||
|
onClick={() => handleSelectRate(rate)}
|
||||||
|
disabled={creating !== null}
|
||||||
|
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
|
||||||
|
className="font-semibold"
|
||||||
|
style={{ color: "var(--admin-text-primary)" }}
|
||||||
|
>
|
||||||
|
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--admin-text-muted)" }}
|
||||||
|
>
|
||||||
|
{rate.deliveryDateLabel
|
||||||
|
? `Arrives ${rate.deliveryDateLabel}`
|
||||||
|
: rate.deliveryDayOfWeek}
|
||||||
|
</div>
|
||||||
|
{rate.isPerishableOnly && (
|
||||||
|
<span className="mt-1.5 inline-block">
|
||||||
|
<AdminBadge tone="warning">Required for perishable</AdminBadge>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div
|
||||||
|
className="ha-num font-bold"
|
||||||
|
style={{ color: "var(--admin-text-primary)" }}
|
||||||
|
>
|
||||||
|
{formatCurrency(rate.totalCharge / 100)}
|
||||||
|
</div>
|
||||||
|
{creating === rate.serviceType && (
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
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
|
||||||
|
</div>
|
||||||
|
<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 flex-wrap gap-2">
|
||||||
|
{result.labelUrl && (
|
||||||
|
<a
|
||||||
|
href={result.labelUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<AdminButton variant="secondary" size="md" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</AdminButton>
|
||||||
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<AdminBadge tone={tone} dot className="shrink-0">
|
||||||
onClick={onClose}
|
{statusLabel(order.shipping_status)}
|
||||||
className="text-slate-400 hover:text-zinc-400 text-2xl leading-none"
|
</AdminBadge>
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
{/* Perishable indicator */}
|
||||||
{/* Perishable warning */}
|
{hasPerishableItems && isPending && (
|
||||||
{rates !== null && rates[0]?.isPerishableOnly && (
|
<div
|
||||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
className="flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold"
|
||||||
<strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available
|
style={{
|
||||||
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
|
backgroundColor: "var(--admin-warning-soft)",
|
||||||
offered for temperature-sensitive items.
|
borderColor: "var(--admin-warning)",
|
||||||
</div>
|
color: "var(--admin-warning)",
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
|
<Sprout className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
|
Perishable — Overnight or 2-Day Air only
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && (
|
{/* Tracking number */}
|
||||||
<div className="flex items-center justify-center py-12">
|
{order.tracking_number && (
|
||||||
<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="flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm"
|
||||||
</div>
|
style={{
|
||||||
)}
|
borderColor: "var(--admin-border-light)",
|
||||||
|
backgroundColor: "var(--admin-bg-subtle)",
|
||||||
{error && (
|
}}
|
||||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
>
|
||||||
{error}
|
<div>
|
||||||
<button
|
<span style={{ color: "var(--admin-text-muted)" }}>Tracking: </span>
|
||||||
onClick={loadRates}
|
<span
|
||||||
className="ml-2 underline hover:no-underline"
|
className="font-mono-ui font-medium"
|
||||||
|
style={{ color: "var(--admin-text-primary)" }}
|
||||||
>
|
>
|
||||||
Retry
|
{order.tracking_number}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{rates !== null && rates.length === 0 && !loading && (
|
{/* Items */}
|
||||||
<div className="text-center py-8 text-zinc-500">
|
{shipItems.length > 0 && (
|
||||||
No FedEx rates available for this address. Please verify the shipping address.
|
<div
|
||||||
</div>
|
className="rounded-xl p-3"
|
||||||
)}
|
style={{ backgroundColor: "var(--admin-bg-subtle)" }}
|
||||||
|
>
|
||||||
{rates !== null && rates.length > 0 && (
|
<ul className="space-y-1">
|
||||||
<div className="space-y-2">
|
{shipItems.map((item) => (
|
||||||
{rates.map((rate) => (
|
<li
|
||||||
<button
|
key={item.id}
|
||||||
key={rate.serviceType}
|
className="flex items-center justify-between text-sm"
|
||||||
onClick={() => handleSelectRate(rate)}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<div>
|
<span style={{ color: "var(--admin-text-secondary)" }}>
|
||||||
<div className="font-semibold text-zinc-100">
|
{item.quantity > 1 && (
|
||||||
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
|
<span
|
||||||
</div>
|
className="font-semibold"
|
||||||
<div className="text-sm text-zinc-500">
|
style={{ color: "var(--admin-text-primary)" }}
|
||||||
{rate.deliveryDateLabel
|
>
|
||||||
? `Arrives ${rate.deliveryDateLabel}`
|
{item.quantity}×{" "}
|
||||||
: rate.deliveryDayOfWeek}
|
|
||||||
</div>
|
|
||||||
{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">
|
|
||||||
Required for perishable
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{item.products?.name ?? "Unknown Product"}
|
||||||
<div className="text-right">
|
{item.products?.is_perishable && (
|
||||||
<div className="font-bold text-zinc-100">
|
<span
|
||||||
${(rate.totalCharge / 100).toFixed(2)}
|
className="ml-1.5 inline-flex items-center gap-1 text-xs"
|
||||||
</div>
|
style={{ color: "var(--admin-warning)" }}
|
||||||
{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" />
|
<Sprout className="h-3 w-3" strokeWidth={2} />
|
||||||
|
perishable
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
</button>
|
<span
|
||||||
))}
|
className="ha-num"
|
||||||
</div>
|
style={{ color: "var(--admin-text-muted)" }}
|
||||||
)}
|
|
||||||
|
|
||||||
{createError && (
|
|
||||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
|
||||||
{createError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-4 py-4 space-y-3">
|
|
||||||
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
|
|
||||||
<span className="text-lg">✓</span> Label Created
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-emerald-600">
|
|
||||||
<div>Tracking: <span className="font-mono font-medium">{result.trackingNumber}</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{result.labelUrl && (
|
|
||||||
<a
|
|
||||||
href={result.labelUrl}
|
|
||||||
target="_blank"
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Download Label
|
{formatCurrency(Number(item.price) * item.quantity)}
|
||||||
</a>
|
</span>
|
||||||
)}
|
</li>
|
||||||
<button
|
))}
|
||||||
onClick={onClose}
|
</ul>
|
||||||
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"
|
<div
|
||||||
>
|
className="mt-2 pt-2 flex justify-between"
|
||||||
Done
|
style={{ borderTop: "1px solid var(--admin-border-light)" }}
|
||||||
</button>
|
>
|
||||||
</div>
|
<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>
|
</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,212 +622,118 @@ 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 className="flex flex-wrap items-center gap-3">
|
||||||
</div>
|
<AdminSearchInput
|
||||||
<div className="flex gap-3">
|
placeholder="Search name, phone, or order #..."
|
||||||
<Link
|
value={search}
|
||||||
href="/admin/orders"
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
onClear={() => setSearch("")}
|
||||||
>
|
containerClassName="min-w-[12rem]"
|
||||||
All Orders
|
/>
|
||||||
</Link>
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<Link
|
<span
|
||||||
href="/admin/pickup"
|
className="text-xs"
|
||||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
style={{ color: "var(--admin-text-muted)" }}
|
||||||
>
|
>
|
||||||
Pickup
|
{orders.length} order{orders.length !== 1 ? "s" : ""}
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<AdminFilterTabs
|
||||||
|
activeTab={statusFilter}
|
||||||
|
onTabChange={(v) => setStatusFilter(v)}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
|
{/* Orders */}
|
||||||
{/* Status filter pills */}
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<AdminCard>
|
||||||
<button
|
<AdminEmptyState
|
||||||
onClick={() => setStatusFilter("")}
|
icon={
|
||||||
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
|
<div
|
||||||
!statusFilter ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
|
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||||
}`}
|
style={{ backgroundColor: "var(--admin-bg-subtle)" }}
|
||||||
>
|
>
|
||||||
All ({orders.length})
|
<Search
|
||||||
</button>
|
className="h-8 w-8"
|
||||||
{SHIPPING_STATUSES.map((s) => (
|
style={{ color: "var(--admin-text-muted)" }}
|
||||||
<button
|
strokeWidth={1.5}
|
||||||
key={s.value}
|
/>
|
||||||
onClick={() => setStatusFilter(s.value)}
|
</div>
|
||||||
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"
|
title={
|
||||||
}`}
|
search || statusFilter
|
||||||
>
|
? "No matching shipping orders"
|
||||||
{s.label} ({counts[s.value] ?? 0})
|
: "No shipping orders"
|
||||||
</button>
|
}
|
||||||
|
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 ? (
|
||||||
|
<AdminButton
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setStatusFilter("");
|
||||||
|
}}
|
||||||
|
icon={<X className="h-4 w-4" strokeWidth={2} />}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
{/* Search */}
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search name, phone, or order #..."
|
|
||||||
value={search}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Orders */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
|
|
||||||
No shipping orders
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<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"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status transitions */}
|
|
||||||
{SHIPPING_STATUSES.filter(
|
|
||||||
(s) =>
|
|
||||||
s.value !== order.shipping_status &&
|
|
||||||
s.value !== "label_created" // handled by create label flow
|
|
||||||
).map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.value}
|
|
||||||
onClick={() => handleStatusChange(order.id, s.value)}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{updating === order.id ? "..." : `Mark ${s.label}`}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</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 && (
|
||||||
@@ -514,4 +741,4 @@ export default function ShippingFulfillmentPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user