Files
route-commerce/src/components/admin/AdminOrdersPanel.tsx
T
openclaw 916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
feat(storage): MinIO object storage, Neon Auth, Supabase removal
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
2026-06-09 12:23:37 -06:00

803 lines
34 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, useMemo, useCallback, useEffect } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { markPickupComplete } from "@/actions/pickup";
import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast, AdminInput, AdminTextInput, AdminSelect } from "./design-system";
import { Skeleton } from "./design-system";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
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;
payment_processor: string | null;
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 AdminOrdersPanelProps = {
initialOrders: Order[];
initialStops: Stop[];
initialProducts?: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }>;
brandId: string | null;
};
type StatusTab = "all" | "pending" | "picked_up";
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
// Icons
const Icons = {
mapPin: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
),
selectAll: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<polyline points="9 11 12 14 22 4"/>
</svg>
),
};
export default function AdminOrdersPanel({
initialOrders,
initialStops,
initialProducts = [],
brandId,
}: AdminOrdersPanelProps) {
const { success: showSuccess, error: showError } = useToast();
const searchParams = useSearchParams();
const [orders, setOrders] = useState<Order[]>(initialOrders);
const [stops] = useState<Stop[]>(initialStops);
const [products] = useState(initialProducts);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<StatusTab>("all");
const [selectedStops, setSelectedStops] = useState<string[]>([]);
// New Order modal state (triggered by ?new=true from dashboard or quick actions)
const [showNewOrderModal, setShowNewOrderModal] = useState(false);
const [newOrderCustomerName, setNewOrderCustomerName] = useState("");
const [newOrderCustomerEmail, setNewOrderCustomerEmail] = useState("");
const [newOrderCustomerPhone, setNewOrderCustomerPhone] = useState("");
const [newOrderStopId, setNewOrderStopId] = useState<string>("");
const [newOrderItems, setNewOrderItems] = useState<Array<{ product_id: string; quantity: number; price: number; fulfillment: "pickup" | "ship" }>>([]);
const [newOrderSubmitting, setNewOrderSubmitting] = useState(false);
const [newOrderError, setNewOrderError] = useState<string | null>(null);
// Open the New Order modal when dashboard links here with ?new=true
useEffect(() => {
const init = async () => {
if (searchParams?.get("new") === "true") {
setShowNewOrderModal(true);
}
};
init();
}, [searchParams]);
// --- New Order (admin manual create) helpers ---
function openNewOrderModal() {
setShowNewOrderModal(true);
setNewOrderError(null);
}
function closeNewOrderModal() {
setShowNewOrderModal(false);
// reset form
setNewOrderCustomerName("");
setNewOrderCustomerEmail("");
setNewOrderCustomerPhone("");
setNewOrderStopId("");
setNewOrderItems([]);
setNewOrderError(null);
}
function addNewOrderItem(productId: string) {
const prod = products.find((p) => p.id === productId);
if (!prod) return;
setNewOrderItems((prev) => [
...prev,
{
product_id: prod.id,
quantity: 1,
price: Number(prod.price),
fulfillment: "pickup",
},
]);
}
function updateNewOrderItem(index: number, patch: Partial<{ quantity: number; price: number; fulfillment: "pickup" | "ship" }>) {
setNewOrderItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...patch } : it)));
}
function removeNewOrderItem(index: number) {
setNewOrderItems((prev) => prev.filter((_, i) => i !== index));
}
const newOrderTotal = newOrderItems.reduce((sum, it) => sum + it.price * it.quantity, 0);
async function handleCreateAdminOrder() {
if (!newOrderCustomerName.trim()) {
setNewOrderError("Customer name is required");
return;
}
if (newOrderItems.length === 0) {
setNewOrderError("Add at least one product");
return;
}
setNewOrderSubmitting(true);
setNewOrderError(null);
const result = await createAdminOrder(brandId, {
customer_name: newOrderCustomerName.trim(),
customer_email: newOrderCustomerEmail.trim() || null,
customer_phone: newOrderCustomerPhone.trim() || null,
stop_id: newOrderStopId || null,
items: newOrderItems.map((i) => ({
product_id: i.product_id,
quantity: i.quantity,
price: i.price,
fulfillment: i.fulfillment,
})),
});
setNewOrderSubmitting(false);
if (result.success) {
showSuccess(`Order created: ${result.orderId.slice(0, 8).toUpperCase()}`);
closeNewOrderModal();
// Navigate to clean orders list (removes ?new=true and shows the new order after refresh)
window.location.href = "/admin/orders";
} else {
setNewOrderError(result.error ?? "Failed to create order");
}
}
const [showStopDropdown, setShowStopDropdown] = useState(false);
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [selectedOrders, setSelectedOrders] = useState<Set<string>>(new Set());
const [bulkMarkingUp, setBulkMarkingUp] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const PAGE_SIZE = 20;
// Simulate loading when orders change
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 300);
return () => clearTimeout(timer);
}, [page, activeTab, search, selectedStops.length]);
const filteredOrders = useMemo(() => {
return orders.filter((order) => {
if (activeTab === "pending" && order.pickup_complete) return false;
if (activeTab === "picked_up" && !order.pickup_complete) return false;
if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) return false;
if (search.trim()) {
const s = search.toLowerCase();
if (!order.customer_name.toLowerCase().includes(s) &&
!(order.customer_phone ?? "").toLowerCase().includes(s) &&
!order.id.toLowerCase().includes(s)) return false;
}
return true;
});
}, [orders, activeTab, selectedStops, search]);
const totalPages = Math.ceil(filteredOrders.length / PAGE_SIZE);
const paginatedOrders = filteredOrders.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const pendingCount = orders.filter((o) => !o.pickup_complete).length;
const pickedUpCount = orders.filter((o) => o.pickup_complete).length;
// Bulk selection
const toggleSelectAll = useCallback(() => {
if (selectedOrders.size === paginatedOrders.length) {
setSelectedOrders(new Set());
} else {
setSelectedOrders(new Set(paginatedOrders.map(o => o.id)));
}
}, [selectedOrders.size, paginatedOrders]);
const toggleOrderSelection = useCallback((orderId: string) => {
setSelectedOrders(prev => {
const next = new Set(prev);
if (next.has(orderId)) {
next.delete(orderId);
} else {
next.add(orderId);
}
return next;
});
}, []);
function toggleStop(stopId: string) {
setSelectedStops((prev) =>
prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId]
);
setPage(0);
}
function clearStops() {
setSelectedStops([]);
setPage(0);
}
async function handleMarkPickup(orderId: string) {
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
showSuccess("Pickup confirmed", `${result.pickup_completed_at ? 'Order marked as picked up' : 'Success'}`);
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
: o
)
);
} else {
showError("Failed to mark pickup", result.error ?? "Please try again");
}
setPickingUp(null);
}
async function handleBulkMarkPickup() {
if (selectedOrders.size === 0) return;
setBulkMarkingUp(true);
let successCount = 0;
let failCount = 0;
for (const orderId of selectedOrders) {
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
successCount++;
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
: o
)
);
} else {
failCount++;
}
}
setBulkMarkingUp(false);
setSelectedOrders(new Set());
if (failCount === 0) {
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
} else {
showError("Some orders failed", `${successCount} succeeded, ${failCount} failed`);
}
}
return (
<>
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.package("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{brandId && (
<AdminBadge variant="info">Brand scoped</AdminBadge>
)}
<AdminButton
onClick={openNewOrderModal}
size="sm"
className="flex items-center gap-2"
>
+ New Order
</AdminButton>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Status Tabs */}
<AdminFilterTabs
activeTab={activeTab}
onTabChange={(value) => { setActiveTab(value as StatusTab); setPage(0); }}
tabs={[
{ value: "all", label: "All", count: filteredOrders.length },
{ value: "pending", label: "Pending", count: pendingCount },
{ value: "picked_up", label: "Picked Up", count: pickedUpCount },
]}
size="md"
/>
{/* Search */}
<AdminSearchInput
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => { setSearch(""); setPage(0); }}
showClear={true}
/>
{/* Stop Filter */}
<div className="relative">
<button
onClick={() => setShowStopDropdown(!showStopDropdown)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
selectedStops.length > 0
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
}`}
>
{Icons.mapPin("h-4 w-4")}
<span>Stops</span>
{selectedStops.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
)}
{Icons.chevronDown("h-4 w-4")}
</button>
{showStopDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => (
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer">
<input
type="checkbox"
checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(stop.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
/>
<div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span>
</div>
</label>
))}
</div>
</div>
</>
)}
</div>
</div>
{/* Selected stop chips */}
{selectedStops.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
{selectedStops.map((stopId) => {
const stop = stops.find((s) => s.id === stopId);
if (!stop) return null;
return (
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]">
{stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]">
{Icons.x("h-3 w-3")}
</button>
</span>
);
})}
</div>
)}
{/* Bulk actions bar */}
{selectedOrders.size > 0 && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setSelectedOrders(new Set())}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
>
Clear
</button>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleBulkMarkPickup}
isLoading={bulkMarkingUp}
icon={Icons.check("h-4 w-4")}
>
Mark All as Picked Up
</AdminButton>
</div>
)}
{/* Orders Table */}
{isLoading ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton variant="rect" className="h-5 w-5 rounded" />
<Skeleton variant="text" className="flex-1" />
<Skeleton variant="text" className="w-24" />
<Skeleton variant="text" className="w-16" />
</div>
))}
</div>
</div>
) : paginatedOrders.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No orders found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm min-w-[700px]">
<thead className="bg-stone-50">
<tr className="border-b border-[var(--admin-border)]">
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
/>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th>
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{paginatedOrders.map((order) => (
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedOrders.has(order.id)}
onChange={() => toggleOrderSelection(order.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
/>
</td>
<td className="px-4 py-3">
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
{shortId(order.id)}
</Link>
</td>
<td className="px-4 py-3">
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div>
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{order.stops ? (
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span>
) : (
<span className="text-stone-300"></span>
)}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span>
</td>
<td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
{order.pickup_complete ? (
<AdminBadge variant="success" dot>Picked Up</AdminBadge>
) : (
<AdminBadge variant="warning" dot>Pending</AdminBadge>
)}
{order.payment_processor === "square" && (
<AdminBadge variant="info">Square</AdminBadge>
)}
{!order.pickup_complete && (
<AdminButton
variant="ghost"
size="sm"
onClick={() => handleMarkPickup(order.id)}
disabled={pickingUp === order.id}
isLoading={pickingUp === order.id}
>
{pickingUp === order.id ? "..." : "Pick Up"}
</AdminButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
</p>
<div className="flex items-center gap-2">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="!rounded-lg"
>
{Icons.chevronLeft("h-4 w-4")}
</AdminIconButton>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="!rounded-lg"
>
{Icons.chevronRight("h-4 w-4")}
</AdminIconButton>
</div>
</div>
)}
</div>
{/* New Order Modal (admin manual entry) - sibling to the main panel content */}
{showNewOrderModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-2xl rounded-2xl border border-[var(--admin-border)] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">New Order (Admin)</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Manual entry for testing / phone orders</p>
</div>
<button onClick={closeNewOrderModal} className="text-2xl leading-none text-stone-400 hover:text-stone-600">×</button>
</div>
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
{newOrderError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-2 text-sm text-red-700">{newOrderError}</div>
)}
{/* Customer */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<AdminInput label="Customer Name" required>
<AdminTextInput
value={newOrderCustomerName}
onChange={(e) => setNewOrderCustomerName(e.target.value)}
placeholder="Jane Doe"
/>
</AdminInput>
<AdminInput label="Email">
<AdminTextInput
value={newOrderCustomerEmail}
onChange={(e) => setNewOrderCustomerEmail(e.target.value)}
placeholder="jane@example.com"
type="email"
/>
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
value={newOrderCustomerPhone}
onChange={(e) => setNewOrderCustomerPhone(e.target.value)}
placeholder="(555) 123-4567"
/>
</AdminInput>
</div>
{/* Stop */}
<AdminInput label="Pickup Stop (leave blank for ship-only)">
<AdminSelect
value={newOrderStopId}
onChange={(e) => setNewOrderStopId(e.target.value)}
options={[
{ value: "", label: "— No stop (ship / manual fulfillment) —" },
...stops.map((s) => ({
value: s.id,
label: `${s.city}, ${s.state}${formatDate(s.date)}`,
})),
]}
/>
</AdminInput>
{/* Items */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)]">Items</label>
<span className="text-xs text-[var(--admin-text-muted)]">Total: ${newOrderTotal.toFixed(2)}</span>
</div>
{products.length > 0 ? (
<div className="mb-3">
<AdminSelect
value=""
onChange={(e) => {
if (e.target.value) addNewOrderItem(e.target.value);
}}
options={[
{ value: "", label: "— Add a product —" },
...products.map((p) => ({ value: p.id, label: `${p.name}$${Number(p.price).toFixed(2)}` })),
]}
/>
</div>
) : (
<p className="text-xs text-amber-600 mb-2">No products loaded for this brand. You can still enter custom items if the action supports it.</p>
)}
{newOrderItems.length === 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">No items yet. Use the selector above.</p>
)}
{newOrderItems.length > 0 && (
<div className="space-y-2 border border-[var(--admin-border)] rounded-xl p-3 bg-[var(--admin-bg)]">
{newOrderItems.map((item, idx) => {
const prod = products.find((p) => p.id === item.product_id);
return (
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm">
<div className="col-span-5 font-medium truncate">{prod?.name ?? item.product_id}</div>
<div className="col-span-2">
<input
type="number"
min={1}
value={item.quantity}
onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full rounded border px-2 py-1 text-sm"
/>
</div>
<div className="col-span-2">
<input
type="number"
step="0.01"
value={item.price}
onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border px-2 py-1 text-sm"
/>
</div>
<div className="col-span-2">
<select
value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm"
>
<option value="pickup">pickup</option>
<option value="ship">ship</option>
</select>
</div>
<div className="col-span-1 text-right">
<button type="button" onClick={() => removeNewOrderItem(idx)} className="text-red-500 text-xs hover:underline">remove</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--admin-border)] px-6 py-4 bg-stone-50 rounded-b-2xl">
<AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}>
Cancel
</AdminButton>
<AdminButton onClick={handleCreateAdminOrder} disabled={newOrderSubmitting || newOrderItems.length === 0}>
{newOrderSubmitting ? "Creating..." : `Create Order — $${newOrderTotal.toFixed(2)}`}
</AdminButton>
</div>
</div>
</div>
)}
</>
);
}