Files
route-commerce/src/components/admin/DriverPickupPanel.tsx
T
tyler 7203cf1ead feat(admin): design system audit fixes
- Add CSS design tokens for consistency (--admin-danger-hover, --admin-accent-dot, shadow-md warm tone)
- Replace unicode icons with inline SVG (⋮, ✕, ▼, ▶)
- Consolidate duplicate PageHeader/AdminPageHeader components
- Standardize border-radius (rounded-xl → rounded-lg for ViewModeTabs)
- Remove hardcoded brand UUID in products page
- Replace hardcoded bg-red-600 with CSS variables in delete buttons
- Add AdminButton, AdminFilterTabs, AdminSearchInput, PageHeader design system components
- Fix GlassModal and AdminModal close button styling
- Remove duplicate 'New Lot' button on Route Trace dashboard
2026-06-02 03:31:54 +00:00

430 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
import AdminBadge from "./design-system/AdminBadge";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
fulfillment?: string;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { id?: string; city: string; state: string; date: string; brand_id?: string } | null;
order_items?: OrderItem[];
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
brand_id?: string;
};
type DriverPickupPanelProps = {
initialPendingOrders: Order[];
initialPickedUpOrders: Order[];
initialStops: Stop[];
brandId: string | null;
canManagePickup: boolean;
};
function formatPickupTime(iso: string | null) {
if (!iso) return "";
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
if (d.getFullYear() === now.getFullYear()) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
export default function DriverPickupPanel({
initialPendingOrders,
initialPickedUpOrders,
initialStops,
brandId,
canManagePickup,
}: DriverPickupPanelProps) {
const [pendingOrders, setPendingOrders] = useState<Order[]>(initialPendingOrders);
const [pickedUpOrders, setPickedUpOrders] = useState<Order[]>(initialPickedUpOrders);
const [stops] = useState<Stop[]>(initialStops);
const [search, setSearch] = useState("");
const [stopFilter, setStopFilter] = useState("");
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [showPickedUp, setShowPickedUp] = useState(true);
const [pickupToast, setPickupToast] = useState<string | null>(null);
const filteredPending = pendingOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !stopFilter || o.stop_id === stopFilter;
return matchesSearch && matchesStop;
});
const filteredPickedUp = pickedUpOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !stopFilter || o.stop_id === stopFilter;
return matchesSearch && matchesStop;
});
const hasAnyPickedUp = pickedUpOrders.length > 0;
async function handleMarkPickup(orderId: string) {
if (!canManagePickup) return;
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
const order = pendingOrders.find((o) => o.id === orderId);
if (order) {
setPendingOrders((prev) => prev.filter((o) => o.id !== orderId));
setPickedUpOrders((prev) => [
{
...order,
pickup_complete: true,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
},
...prev,
]);
setPickupToast(orderId);
setTimeout(() => setPickupToast(null), 3000);
}
}
setPickingUp(null);
}
return (
<div className="min-h-screen bg-stone-50">
{/* Header */}
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-5xl px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Driver Pickup</h1>
<p className="mt-1 text-sm text-stone-500">
{filteredPending.length} pending
{hasAnyPickedUp && ` · ${pickedUpOrders.length} picked up`}
</p>
</div>
<Link
href="/admin/orders"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
All Orders
</Link>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-6 space-y-5">
{/* Filters */}
<div className="flex gap-3">
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {s.date}
</option>
))}
</select>
<div className="relative flex-1">
<svg className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white pl-11 pr-4 py-3 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
{/* Pending Orders */}
{filteredPending.length === 0 ? (
<div className="rounded-2xl bg-white border border-stone-200 py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p className="text-lg font-medium text-stone-600">No pending orders</p>
<p className="mt-1 text-sm text-stone-400">Orders awaiting pickup will appear here</p>
</div>
) : (
<div className="space-y-4">
{filteredPending.map((order) => (
<OrderCard
key={order.id}
order={order}
canManagePickup={canManagePickup}
onMarkPickup={handleMarkPickup}
pickingUp={pickingUp}
shortId={shortId(order.id)}
/>
))}
</div>
)}
{/* Picked Up Toggle */}
{hasAnyPickedUp && (
<button
onClick={() => setShowPickedUp((v) => !v)}
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
>
{showPickedUp ? (
<><svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7"/></svg> Hide</>
) : (
<><svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/></svg> Show</>
)}
{pickedUpOrders.length} picked up
</button>
)}
{showPickedUp && filteredPickedUp.length > 0 && (
<div className="space-y-3">
{filteredPickedUp.map((order) => (
<PickedUpCard
key={order.id}
order={order}
shortId={shortId(order.id)}
/>
))}
</div>
)}
{showPickedUp && filteredPickedUp.length === 0 && stopFilter && (
<div className="rounded-xl border border-stone-200 bg-stone-50 py-6 text-center text-sm text-stone-500">
No picked-up orders for this stop
</div>
)}
{/* Toast */}
{pickupToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200/50">
Order picked up
</div>
)}
</main>
</div>
);
}
type OrderCardProps = {
order: Order;
canManagePickup: boolean;
onMarkPickup: (orderId: string) => void;
pickingUp: string | null;
shortId: string;
};
function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }: OrderCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment !== "shipping");
const shippingItems = allItems.filter((i) => i.fulfillment === "shipping");
const isMixed = pickupItems.length > 0 && shippingItems.length > 0;
const pickupTotal = pickupItems.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0);
return (
<div className="rounded-2xl bg-white border border-stone-200 p-6 shadow-sm">
{/* Header row */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-lg font-semibold text-stone-950">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-stone-400 uppercase">{shortId}</p>
</div>
<div className="flex flex-col items-end gap-2">
<AdminBadge variant="warning">Pending</AdminBadge>
{isMixed && (
<AdminBadge variant="info">Mixed</AdminBadge>
)}
</div>
</div>
{/* Items */}
{pickupItems.length > 0 && (
<div className="mt-5 rounded-xl bg-stone-50 border border-stone-100 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500">
Pickup Items
</span>
<span className="text-xs text-stone-400">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} total
</span>
</div>
<ul className="space-y-2">
{pickupItems.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-stone-700">
{item.quantity > 1 && (
<span className="font-semibold text-stone-900">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
</span>
<span className="text-stone-500 font-medium">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 border-t border-stone-200 pt-3 flex justify-between">
<span className="text-sm font-medium text-stone-600">Subtotal</span>
<span className="text-sm font-bold text-stone-900">${pickupTotal.toFixed(2)}</span>
</div>
</div>
)}
{/* Shipping warning */}
{isMixed && (
<div className="mt-4 rounded-lg bg-violet-50 border border-violet-100 px-4 py-3 text-sm text-violet-700">
{shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} will also be marked picked up
</div>
)}
{/* Footer */}
<div className="mt-5 flex items-center justify-between">
<div>
{order.stops ? (
<>
<p className="font-medium text-stone-700">{order.stops.city}, {order.stops.state}</p>
<p className="text-sm text-stone-400">
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
</p>
</>
) : (
<p className="text-stone-400">No stop assigned</p>
)}
</div>
<p className="text-2xl font-bold text-stone-950">${pickupTotal.toFixed(2)}</p>
</div>
{/* Actions */}
{canManagePickup ? (
<div className="mt-5 grid grid-cols-2 gap-3">
<Link
href={`/admin/orders/${order.id}`}
className="flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
View Details
</Link>
<button
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{pickingUp === order.id ? (
"..."
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Pick Up
</>
)}
</button>
</div>
) : (
<div className="mt-5 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-center text-sm text-stone-400">
No pickup permission
</div>
)}
</div>
);
}
type PickedUpCardProps = {
order: Order;
shortId: string;
};
function PickedUpCard({ order, shortId }: PickedUpCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment === "pickup");
const pickupTotal = pickupItems.reduce((s, i) => s + Number(i.price) * i.quantity, 0);
const isMixed = allItems.some((i) => i.fulfillment === "shipping");
return (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/50 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-semibold text-stone-900">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
)}
<p className="mt-1 text-xs text-stone-400">
{shortId} · {order.stops?.city}, {order.stops?.state}
</p>
{pickupItems.length > 0 && (
<ul className="mt-3 space-y-1">
{pickupItems.map((item) => (
<li key={item.id} className="text-sm text-stone-600">
{item.quantity > 1 && <span className="font-semibold">{item.quantity}× </span>}
{item.products?.name ?? "Unknown"}
<span className="ml-2 text-stone-400">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-stone-500">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} items · ${pickupTotal.toFixed(2)}
{isMixed && <span className="ml-2 text-violet-600">· mixed</span>}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<AdminBadge variant="success">Picked Up</AdminBadge>
{order.pickup_completed_at && (
<p className="text-xs text-stone-400">{formatPickupTime(order.pickup_completed_at)}</p>
)}
</div>
</div>
</div>
);
}