fe78645609
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms) - Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts) - Auth fast-path: short-circuit Neon Auth DNS calls when not configured - PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking - Stale build artifacts fix (clean rebuild restores fast behavior) Measured (production build, sequential requests, dev_session cookie): - 102/103 pages: TTFB ≤ 10ms - 1 page: TTFB 11-20ms - 0 pages exceed 50ms TTFB - First Paint (browser): 28-84ms on admin pages
1674 lines
54 KiB
TypeScript
1674 lines
54 KiB
TypeScript
"use client";
|
||
|
||
import { useReducer, useMemo, useCallback, useEffect, useTransition, useRef } from "react";
|
||
import Link from "next/link";
|
||
import { useSearchParams } from "next/navigation";
|
||
import {
|
||
Check,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
MapPin,
|
||
Package,
|
||
X,
|
||
} from "lucide-react";
|
||
import { markPickupComplete } from "@/actions/pickup";
|
||
import { createAdminOrder } from "@/actions/orders/create-admin-order";
|
||
import { formatDate } from "@/lib/format-date";
|
||
import AdminBadge from "./design-system/AdminBadge";
|
||
import AdminButton, { AdminIconButton } from "./design-system/AdminButton";
|
||
import AdminSearchInput from "./design-system/AdminSearchInput";
|
||
import AdminFilterTabs from "./design-system/AdminFilterTabs";
|
||
import { useToast } from "./Toast";
|
||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
|
||
import EmptyState from "./EmptyState";
|
||
import KPIStat from "./KPIStat";
|
||
import { Skeleton } from "./design-system/Skeleton";
|
||
|
||
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 Product = {
|
||
id: string;
|
||
name: string;
|
||
price: number;
|
||
type?: string | null;
|
||
active?: boolean;
|
||
};
|
||
|
||
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";
|
||
|
||
type NewOrderItem = { product_id: string; quantity: number; price: number; fulfillment: "pickup" | "ship" };
|
||
|
||
function shortId(id: string) {
|
||
return id.slice(0, 8).toUpperCase();
|
||
}
|
||
|
||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||
|
||
function formatCurrency(amount: number) {
|
||
return currencyFormatter.format(amount);
|
||
}
|
||
|
||
// Icon wrappers (lucide-react) — kept compact for inline use.
|
||
const MapPinIcon = () => <MapPin className="h-4 w-4" strokeWidth={2} />;
|
||
const CheckIcon = () => <Check className="h-4 w-4" strokeWidth={2} />;
|
||
const XIcon = () => <X className="h-3 w-3" strokeWidth={2} />;
|
||
const ChevronDownIcon = () => <ChevronDown className="h-4 w-4" strokeWidth={2} />;
|
||
const ChevronLeftIcon = () => <ChevronLeft className="h-4 w-4" strokeWidth={2} />;
|
||
const ChevronRightIcon = () => <ChevronRight className="h-4 w-4" strokeWidth={2} />;
|
||
|
||
const EMPTY_PRODUCTS: Product[] = [];
|
||
|
||
// ── State / Reducer ────────────────────────────────────────────────────────────
|
||
|
||
type State = {
|
||
orders: Order[];
|
||
search: string;
|
||
activeTab: StatusTab;
|
||
selectedStops: string[];
|
||
showNewOrderModal: boolean;
|
||
newOrderCustomerName: string;
|
||
newOrderCustomerEmail: string;
|
||
newOrderCustomerPhone: string;
|
||
newOrderStopId: string;
|
||
newOrderItems: NewOrderItem[];
|
||
newOrderSubmitting: boolean;
|
||
newOrderError: string | null;
|
||
showStopDropdown: boolean;
|
||
pickingUp: string | null;
|
||
page: number;
|
||
selectedOrders: Set<string>;
|
||
bulkMarkingUp: boolean;
|
||
};
|
||
|
||
type Action =
|
||
| { type: "SET_ORDERS"; value: Order[] }
|
||
| { type: "MARK_PICKUP"; orderId: string; completedAt: string | null; completedBy: string | null }
|
||
| { type: "SET_SEARCH"; value: string }
|
||
| { type: "SET_ACTIVE_TAB"; value: StatusTab }
|
||
| { type: "RESET_PAGE" }
|
||
| { type: "TOGGLE_STOP"; stopId: string }
|
||
| { type: "CLEAR_STOPS" }
|
||
| { type: "OPEN_NEW_ORDER_MODAL" }
|
||
| { type: "CLOSE_NEW_ORDER_MODAL" }
|
||
| { type: "SET_NEW_ORDER_CUSTOMER_NAME"; value: string }
|
||
| { type: "SET_NEW_ORDER_CUSTOMER_EMAIL"; value: string }
|
||
| { type: "SET_NEW_ORDER_CUSTOMER_PHONE"; value: string }
|
||
| { type: "SET_NEW_ORDER_STOP_ID"; value: string }
|
||
| { type: "SET_NEW_ORDER_ERROR"; value: string | null }
|
||
| { type: "ADD_NEW_ORDER_ITEM"; item: NewOrderItem }
|
||
| { type: "UPDATE_NEW_ORDER_ITEM"; index: number; patch: Partial<NewOrderItem> }
|
||
| { type: "REMOVE_NEW_ORDER_ITEM"; index: number }
|
||
| { type: "SET_NEW_ORDER_SUBMITTING"; value: boolean }
|
||
| { type: "TOGGLE_STOP_DROPDOWN"; value: boolean }
|
||
| { type: "SET_PICKING_UP"; value: string | null }
|
||
| { type: "SET_PAGE"; value: number }
|
||
| { type: "TOGGLE_SELECT_ALL"; paginatedIds: string[] }
|
||
| { type: "TOGGLE_ORDER_SELECTION"; orderId: string }
|
||
| { type: "CLEAR_ORDER_SELECTION" }
|
||
| { type: "SET_BULK_MARKING_UP"; value: boolean };
|
||
|
||
const initialState: State = {
|
||
orders: [],
|
||
search: "",
|
||
activeTab: "all",
|
||
selectedStops: [],
|
||
showNewOrderModal: false,
|
||
newOrderCustomerName: "",
|
||
newOrderCustomerEmail: "",
|
||
newOrderCustomerPhone: "",
|
||
newOrderStopId: "",
|
||
newOrderItems: [],
|
||
newOrderSubmitting: false,
|
||
newOrderError: null,
|
||
showStopDropdown: false,
|
||
pickingUp: null,
|
||
page: 0,
|
||
selectedOrders: new Set(),
|
||
bulkMarkingUp: false,
|
||
};
|
||
|
||
function reducer(state: State, action: Action): State {
|
||
switch (action.type) {
|
||
case "SET_ORDERS":
|
||
return { ...state, orders: action.value };
|
||
case "MARK_PICKUP":
|
||
return {
|
||
...state,
|
||
orders: state.orders.map((o) =>
|
||
o.id === action.orderId
|
||
? {
|
||
...o,
|
||
pickup_complete: true,
|
||
pickup_completed_at: action.completedAt,
|
||
pickup_completed_by: action.completedBy,
|
||
}
|
||
: o
|
||
),
|
||
};
|
||
case "SET_SEARCH":
|
||
return { ...state, search: action.value };
|
||
case "SET_ACTIVE_TAB":
|
||
return { ...state, activeTab: action.value };
|
||
case "RESET_PAGE":
|
||
return { ...state, page: 0 };
|
||
case "TOGGLE_STOP":
|
||
return {
|
||
...state,
|
||
selectedStops: state.selectedStops.includes(action.stopId)
|
||
? state.selectedStops.filter((id) => id !== action.stopId)
|
||
: [...state.selectedStops, action.stopId],
|
||
page: 0,
|
||
};
|
||
case "CLEAR_STOPS":
|
||
return { ...state, selectedStops: [], page: 0 };
|
||
case "OPEN_NEW_ORDER_MODAL":
|
||
return { ...state, showNewOrderModal: true, newOrderError: null };
|
||
case "CLOSE_NEW_ORDER_MODAL":
|
||
return {
|
||
...state,
|
||
showNewOrderModal: false,
|
||
newOrderCustomerName: "",
|
||
newOrderCustomerEmail: "",
|
||
newOrderCustomerPhone: "",
|
||
newOrderStopId: "",
|
||
newOrderItems: [],
|
||
newOrderError: null,
|
||
};
|
||
case "SET_NEW_ORDER_CUSTOMER_NAME":
|
||
return { ...state, newOrderCustomerName: action.value };
|
||
case "SET_NEW_ORDER_CUSTOMER_EMAIL":
|
||
return { ...state, newOrderCustomerEmail: action.value };
|
||
case "SET_NEW_ORDER_CUSTOMER_PHONE":
|
||
return { ...state, newOrderCustomerPhone: action.value };
|
||
case "SET_NEW_ORDER_STOP_ID":
|
||
return { ...state, newOrderStopId: action.value };
|
||
case "SET_NEW_ORDER_ERROR":
|
||
return { ...state, newOrderError: action.value };
|
||
case "ADD_NEW_ORDER_ITEM":
|
||
return { ...state, newOrderItems: [...state.newOrderItems, action.item] };
|
||
case "UPDATE_NEW_ORDER_ITEM":
|
||
return {
|
||
...state,
|
||
newOrderItems: state.newOrderItems.map((it, i) => (i === action.index ? { ...it, ...action.patch } : it)),
|
||
};
|
||
case "REMOVE_NEW_ORDER_ITEM":
|
||
return {
|
||
...state,
|
||
newOrderItems: state.newOrderItems.filter((_, i) => i !== action.index),
|
||
};
|
||
case "SET_NEW_ORDER_SUBMITTING":
|
||
return { ...state, newOrderSubmitting: action.value };
|
||
case "TOGGLE_STOP_DROPDOWN":
|
||
return { ...state, showStopDropdown: action.value };
|
||
case "SET_PICKING_UP":
|
||
return { ...state, pickingUp: action.value };
|
||
case "SET_PAGE":
|
||
return { ...state, page: action.value };
|
||
case "TOGGLE_SELECT_ALL":
|
||
return {
|
||
...state,
|
||
selectedOrders:
|
||
state.selectedOrders.size === action.paginatedIds.length
|
||
? new Set()
|
||
: new Set(action.paginatedIds),
|
||
};
|
||
case "TOGGLE_ORDER_SELECTION": {
|
||
const next = new Set(state.selectedOrders);
|
||
if (next.has(action.orderId)) {
|
||
next.delete(action.orderId);
|
||
} else {
|
||
next.add(action.orderId);
|
||
}
|
||
return { ...state, selectedOrders: next };
|
||
}
|
||
case "CLEAR_ORDER_SELECTION":
|
||
return { ...state, selectedOrders: new Set() };
|
||
case "SET_BULK_MARKING_UP":
|
||
return { ...state, bulkMarkingUp: action.value };
|
||
}
|
||
}
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
export default function AdminOrdersPanel({
|
||
initialOrders,
|
||
initialStops,
|
||
initialProducts = EMPTY_PRODUCTS,
|
||
brandId,
|
||
}: AdminOrdersPanelProps) {
|
||
const { success: showSuccess, error: showError } = useToast();
|
||
const searchParams = useSearchParams();
|
||
const [state, dispatch] = useReducer(reducer, { ...initialState, orders: initialOrders });
|
||
const {
|
||
orders, search, activeTab, selectedStops, showNewOrderModal,
|
||
newOrderCustomerName, newOrderCustomerEmail, newOrderCustomerPhone, newOrderStopId,
|
||
newOrderItems, newOrderSubmitting, newOrderError, showStopDropdown, pickingUp, page,
|
||
selectedOrders, bulkMarkingUp,
|
||
} = state;
|
||
const stops = initialStops;
|
||
const products = initialProducts;
|
||
const [isPending, startTransition] = useTransition();
|
||
const filterVersionRef = useRef(0);
|
||
|
||
// Open the New Order modal when dashboard links here with ?new=true
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
if (searchParams?.get("new") === "true") {
|
||
dispatch({ type: "OPEN_NEW_ORDER_MODAL" });
|
||
}
|
||
};
|
||
void init();
|
||
}, [searchParams]);
|
||
|
||
// Mark the filter operation as a transition whenever the input
|
||
// dependencies change, so React can defer the heavy list work and
|
||
// give us an `isPending` flag instead of a fake useState round-trip.
|
||
useEffect(() => {
|
||
startTransition(() => {
|
||
filterVersionRef.current = filterVersionRef.current + 1;
|
||
});
|
||
}, [page, activeTab, search, selectedStops.length, startTransition]);
|
||
|
||
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;
|
||
const newOrderTotal = newOrderItems.reduce((sum, it) => sum + it.price * it.quantity, 0);
|
||
|
||
// Bulk selection
|
||
const toggleSelectAll = useCallback(() => {
|
||
dispatch({ type: "TOGGLE_SELECT_ALL", paginatedIds: paginatedOrders.map((o) => o.id) });
|
||
}, [paginatedOrders]);
|
||
|
||
const toggleOrderSelection = useCallback((orderId: string) => {
|
||
dispatch({ type: "TOGGLE_ORDER_SELECTION", orderId });
|
||
}, []);
|
||
|
||
// --- New Order (admin manual create) handlers ---
|
||
function openNewOrderModal() {
|
||
dispatch({ type: "OPEN_NEW_ORDER_MODAL" });
|
||
}
|
||
|
||
function closeNewOrderModal() {
|
||
dispatch({ type: "CLOSE_NEW_ORDER_MODAL" });
|
||
}
|
||
|
||
function addNewOrderItem(productId: string) {
|
||
const prod = products.find((p) => p.id === productId);
|
||
if (!prod) return;
|
||
dispatch({
|
||
type: "ADD_NEW_ORDER_ITEM",
|
||
item: {
|
||
product_id: prod.id,
|
||
quantity: 1,
|
||
price: Number(prod.price),
|
||
fulfillment: "pickup",
|
||
},
|
||
});
|
||
}
|
||
|
||
function updateNewOrderItem(index: number, patch: Partial<NewOrderItem>) {
|
||
dispatch({ type: "UPDATE_NEW_ORDER_ITEM", index, patch });
|
||
}
|
||
|
||
function removeNewOrderItem(index: number) {
|
||
dispatch({ type: "REMOVE_NEW_ORDER_ITEM", index });
|
||
}
|
||
|
||
function toggleStop(stopId: string) {
|
||
dispatch({ type: "TOGGLE_STOP", stopId });
|
||
}
|
||
|
||
function clearStops() {
|
||
dispatch({ type: "CLEAR_STOPS" });
|
||
}
|
||
|
||
async function handleCreateAdminOrder() {
|
||
if (!newOrderCustomerName.trim()) {
|
||
dispatch({ type: "SET_NEW_ORDER_ERROR", value: "Customer name is required" });
|
||
return;
|
||
}
|
||
if (newOrderItems.length === 0) {
|
||
dispatch({ type: "SET_NEW_ORDER_ERROR", value: "Add at least one product" });
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: "SET_NEW_ORDER_SUBMITTING", value: true });
|
||
dispatch({ type: "SET_NEW_ORDER_ERROR", value: 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,
|
||
})),
|
||
});
|
||
|
||
dispatch({ type: "SET_NEW_ORDER_SUBMITTING", value: 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 {
|
||
dispatch({ type: "SET_NEW_ORDER_ERROR", value: result.error ?? "Failed to create order" });
|
||
}
|
||
}
|
||
|
||
async function handleMarkPickup(orderId: string) {
|
||
dispatch({ type: "SET_PICKING_UP", value: orderId });
|
||
const result = await markPickupComplete(orderId, brandId);
|
||
if (result.success) {
|
||
showSuccess("Pickup confirmed", `${result.pickup_completed_at ? 'Order marked as picked up' : 'Success'}`);
|
||
dispatch({
|
||
type: "MARK_PICKUP",
|
||
orderId,
|
||
completedAt: result.pickup_completed_at,
|
||
completedBy: result.pickup_completed_by,
|
||
});
|
||
} else {
|
||
showError("Failed to mark pickup", result.error ?? "Please try again");
|
||
}
|
||
dispatch({ type: "SET_PICKING_UP", value: null });
|
||
}
|
||
|
||
async function handleBulkMarkPickup() {
|
||
if (selectedOrders.size === 0) return;
|
||
|
||
dispatch({ type: "SET_BULK_MARKING_UP", value: true });
|
||
const orderIds = Array.from(selectedOrders);
|
||
|
||
const results = await Promise.all(
|
||
orderIds.map(async (orderId) => {
|
||
try {
|
||
const result = await markPickupComplete(orderId, brandId);
|
||
if (result.success) {
|
||
dispatch({
|
||
type: "MARK_PICKUP",
|
||
orderId,
|
||
completedAt: result.pickup_completed_at,
|
||
completedBy: result.pickup_completed_by,
|
||
});
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
})
|
||
);
|
||
|
||
const successCount = results.filter((r) => r).length;
|
||
const failCount = results.length - successCount;
|
||
|
||
dispatch({ type: "SET_BULK_MARKING_UP", value: false });
|
||
dispatch({ type: "CLEAR_ORDER_SELECTION" });
|
||
|
||
if (failCount === 0) {
|
||
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
|
||
} else {
|
||
showError("Some orders failed", `${successCount} succeeded, ${failCount} failed`);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<OrdersAdminView
|
||
brandId={brandId}
|
||
isPending={isPending}
|
||
orders={orders}
|
||
filteredOrders={filteredOrders}
|
||
paginatedOrders={paginatedOrders}
|
||
pendingCount={pendingCount}
|
||
pickedUpCount={pickedUpCount}
|
||
totalPages={totalPages}
|
||
page={page}
|
||
search={search}
|
||
activeTab={activeTab}
|
||
selectedStops={selectedStops}
|
||
selectedOrders={selectedOrders}
|
||
bulkMarkingUp={bulkMarkingUp}
|
||
showStopDropdown={showStopDropdown}
|
||
pickingUp={pickingUp}
|
||
stops={stops}
|
||
products={products}
|
||
showNewOrderModal={showNewOrderModal}
|
||
newOrderCustomerName={newOrderCustomerName}
|
||
newOrderCustomerEmail={newOrderCustomerEmail}
|
||
newOrderCustomerPhone={newOrderCustomerPhone}
|
||
newOrderStopId={newOrderStopId}
|
||
newOrderItems={newOrderItems}
|
||
newOrderSubmitting={newOrderSubmitting}
|
||
newOrderError={newOrderError}
|
||
newOrderTotal={newOrderTotal}
|
||
onActiveTabChange={(value) => {
|
||
dispatch({ type: "SET_ACTIVE_TAB", value });
|
||
dispatch({ type: "RESET_PAGE" });
|
||
}}
|
||
onSearchChange={(value) => {
|
||
dispatch({ type: "SET_SEARCH", value });
|
||
dispatch({ type: "RESET_PAGE" });
|
||
}}
|
||
onSearchClear={() => {
|
||
dispatch({ type: "SET_SEARCH", value: "" });
|
||
dispatch({ type: "RESET_PAGE" });
|
||
}}
|
||
onPageChange={(p) => dispatch({ type: "SET_PAGE", value: p })}
|
||
onToggleSelectAll={toggleSelectAll}
|
||
onToggleOrderSelection={toggleOrderSelection}
|
||
onClearOrderSelection={() => dispatch({ type: "CLEAR_ORDER_SELECTION" })}
|
||
onBulkMarkPickup={handleBulkMarkPickup}
|
||
onMarkPickup={handleMarkPickup}
|
||
onToggleStopDropdown={(value) => dispatch({ type: "TOGGLE_STOP_DROPDOWN", value })}
|
||
onToggleStop={toggleStop}
|
||
onClearStops={clearStops}
|
||
onOpenNewOrderModal={openNewOrderModal}
|
||
onCloseNewOrderModal={closeNewOrderModal}
|
||
onSetNewOrderCustomerName={(value) => dispatch({ type: "SET_NEW_ORDER_CUSTOMER_NAME", value })}
|
||
onSetNewOrderCustomerEmail={(value) => dispatch({ type: "SET_NEW_ORDER_CUSTOMER_EMAIL", value })}
|
||
onSetNewOrderCustomerPhone={(value) => dispatch({ type: "SET_NEW_ORDER_CUSTOMER_PHONE", value })}
|
||
onSetNewOrderStopId={(value) => dispatch({ type: "SET_NEW_ORDER_STOP_ID", value })}
|
||
onAddNewOrderItem={addNewOrderItem}
|
||
onUpdateNewOrderItem={updateNewOrderItem}
|
||
onRemoveNewOrderItem={removeNewOrderItem}
|
||
onCreateAdminOrder={handleCreateAdminOrder}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── OrdersAdminView (presentation) ─────────────────────────────────────────────
|
||
|
||
function OrdersAdminView({
|
||
brandId,
|
||
isPending,
|
||
orders,
|
||
filteredOrders,
|
||
paginatedOrders,
|
||
pendingCount,
|
||
pickedUpCount,
|
||
totalPages,
|
||
page,
|
||
search,
|
||
activeTab,
|
||
selectedStops,
|
||
selectedOrders,
|
||
bulkMarkingUp,
|
||
showStopDropdown,
|
||
pickingUp,
|
||
stops,
|
||
products,
|
||
showNewOrderModal,
|
||
newOrderCustomerName,
|
||
newOrderCustomerEmail,
|
||
newOrderCustomerPhone,
|
||
newOrderStopId,
|
||
newOrderItems,
|
||
newOrderSubmitting,
|
||
newOrderError,
|
||
newOrderTotal,
|
||
onActiveTabChange,
|
||
onSearchChange,
|
||
onSearchClear,
|
||
onPageChange,
|
||
onToggleSelectAll,
|
||
onToggleOrderSelection,
|
||
onClearOrderSelection,
|
||
onBulkMarkPickup,
|
||
onMarkPickup,
|
||
onToggleStopDropdown,
|
||
onToggleStop,
|
||
onClearStops,
|
||
onOpenNewOrderModal,
|
||
onCloseNewOrderModal,
|
||
onSetNewOrderCustomerName,
|
||
onSetNewOrderCustomerEmail,
|
||
onSetNewOrderCustomerPhone,
|
||
onSetNewOrderStopId,
|
||
onAddNewOrderItem,
|
||
onUpdateNewOrderItem,
|
||
onRemoveNewOrderItem,
|
||
onCreateAdminOrder,
|
||
}: {
|
||
brandId: string | null;
|
||
isPending: boolean;
|
||
orders: Order[];
|
||
filteredOrders: Order[];
|
||
paginatedOrders: Order[];
|
||
pendingCount: number;
|
||
pickedUpCount: number;
|
||
totalPages: number;
|
||
page: number;
|
||
search: string;
|
||
activeTab: StatusTab;
|
||
selectedStops: string[];
|
||
selectedOrders: Set<string>;
|
||
bulkMarkingUp: boolean;
|
||
showStopDropdown: boolean;
|
||
pickingUp: string | null;
|
||
stops: Stop[];
|
||
products: Product[];
|
||
showNewOrderModal: boolean;
|
||
newOrderCustomerName: string;
|
||
newOrderCustomerEmail: string;
|
||
newOrderCustomerPhone: string;
|
||
newOrderStopId: string;
|
||
newOrderItems: NewOrderItem[];
|
||
newOrderSubmitting: boolean;
|
||
newOrderError: string | null;
|
||
newOrderTotal: number;
|
||
onActiveTabChange: (value: StatusTab) => void;
|
||
onSearchChange: (value: string) => void;
|
||
onSearchClear: () => void;
|
||
onPageChange: (p: number) => void;
|
||
onToggleSelectAll: () => void;
|
||
onToggleOrderSelection: (orderId: string) => void;
|
||
onClearOrderSelection: () => void;
|
||
onBulkMarkPickup: () => void;
|
||
onMarkPickup: (orderId: string) => void;
|
||
onToggleStopDropdown: (value: boolean) => void;
|
||
onToggleStop: (stopId: string) => void;
|
||
onClearStops: () => void;
|
||
onOpenNewOrderModal: () => void;
|
||
onCloseNewOrderModal: () => void;
|
||
onSetNewOrderCustomerName: (value: string) => void;
|
||
onSetNewOrderCustomerEmail: (value: string) => void;
|
||
onSetNewOrderCustomerPhone: (value: string) => void;
|
||
onSetNewOrderStopId: (value: string) => void;
|
||
onAddNewOrderItem: (productId: string) => void;
|
||
onUpdateNewOrderItem: (index: number, patch: Partial<NewOrderItem>) => void;
|
||
onRemoveNewOrderItem: (index: number) => void;
|
||
onCreateAdminOrder: () => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
<div className="p-4 sm:p-6">
|
||
<OrdersPageHeader
|
||
filteredCount={filteredOrders.length}
|
||
brandId={brandId}
|
||
onNewOrder={onOpenNewOrderModal}
|
||
/>
|
||
<OrdersStatsCards
|
||
isPending={isPending}
|
||
ordersCount={orders.length}
|
||
pendingCount={pendingCount}
|
||
pickedUpCount={pickedUpCount}
|
||
/>
|
||
<OrdersFilterBar
|
||
activeTab={activeTab}
|
||
onActiveTabChange={onActiveTabChange}
|
||
filteredCount={filteredOrders.length}
|
||
pendingCount={pendingCount}
|
||
pickedUpCount={pickedUpCount}
|
||
search={search}
|
||
onSearchChange={onSearchChange}
|
||
onSearchClear={onSearchClear}
|
||
stops={stops}
|
||
selectedStops={selectedStops}
|
||
showStopDropdown={showStopDropdown}
|
||
onToggleStopDropdown={onToggleStopDropdown}
|
||
onToggleStop={onToggleStop}
|
||
onClearStops={onClearStops}
|
||
/>
|
||
<SelectedStopChips
|
||
stops={stops}
|
||
selectedStops={selectedStops}
|
||
onToggleStop={onToggleStop}
|
||
/>
|
||
<BulkActionsBar
|
||
selectedCount={selectedOrders.size}
|
||
bulkMarkingUp={bulkMarkingUp}
|
||
onClear={onClearOrderSelection}
|
||
onMarkAll={onBulkMarkPickup}
|
||
/>
|
||
<OrdersTableSection
|
||
isPending={isPending}
|
||
ordersCount={orders.length}
|
||
paginatedOrders={paginatedOrders}
|
||
selectedOrders={selectedOrders}
|
||
pickingUp={pickingUp}
|
||
onToggleSelectAll={onToggleSelectAll}
|
||
onToggleOrderSelection={onToggleOrderSelection}
|
||
onMarkPickup={onMarkPickup}
|
||
/>
|
||
<OrdersPagination
|
||
page={page}
|
||
totalPages={totalPages}
|
||
totalCount={filteredOrders.length}
|
||
onPageChange={onPageChange}
|
||
/>
|
||
</div>
|
||
{showNewOrderModal && (
|
||
<NewOrderModal
|
||
stops={stops}
|
||
products={products}
|
||
customerName={newOrderCustomerName}
|
||
customerEmail={newOrderCustomerEmail}
|
||
customerPhone={newOrderCustomerPhone}
|
||
stopId={newOrderStopId}
|
||
items={newOrderItems}
|
||
submitting={newOrderSubmitting}
|
||
error={newOrderError}
|
||
total={newOrderTotal}
|
||
onClose={onCloseNewOrderModal}
|
||
onSetCustomerName={onSetNewOrderCustomerName}
|
||
onSetCustomerEmail={onSetNewOrderCustomerEmail}
|
||
onSetCustomerPhone={onSetNewOrderCustomerPhone}
|
||
onSetStopId={onSetNewOrderStopId}
|
||
onAddItem={onAddNewOrderItem}
|
||
onUpdateItem={onUpdateNewOrderItem}
|
||
onRemoveItem={onRemoveNewOrderItem}
|
||
onSubmit={onCreateAdminOrder}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Page Header ────────────────────────────────────────────────────────────────
|
||
|
||
function OrdersPageHeader({
|
||
filteredCount,
|
||
brandId,
|
||
onNewOrder,
|
||
}: {
|
||
filteredCount: number;
|
||
brandId: string | null;
|
||
onNewOrder: () => void;
|
||
}) {
|
||
return (
|
||
<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"
|
||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||
>
|
||
<Package className="h-5 w-5 text-white" strokeWidth={2} />
|
||
</div>
|
||
<div>
|
||
<h2
|
||
className="text-base sm:text-lg font-bold"
|
||
style={{ color: "var(--admin-text-primary)" }}
|
||
>
|
||
Orders
|
||
</h2>
|
||
<p
|
||
className="text-[10px] sm:text-xs"
|
||
style={{ color: "var(--admin-text-muted)" }}
|
||
>
|
||
{filteredCount} order{filteredCount !== 1 ? "s" : ""}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{brandId && (
|
||
<AdminBadge tone="info">Brand scoped</AdminBadge>
|
||
)}
|
||
<AdminButton
|
||
onClick={onNewOrder}
|
||
size="sm"
|
||
className="flex items-center gap-2"
|
||
>
|
||
+ New Order
|
||
</AdminButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Stats Cards ───────────────────────────────────────────────────────────────
|
||
|
||
function OrdersStatsCards({
|
||
isPending,
|
||
ordersCount,
|
||
pendingCount,
|
||
pickedUpCount,
|
||
}: {
|
||
isPending: boolean;
|
||
ordersCount: number;
|
||
pendingCount: number;
|
||
pickedUpCount: number;
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
||
<KPIStat
|
||
label="Total"
|
||
value={isPending ? <Skeleton variant="text" className="w-16 h-8" /> : ordersCount}
|
||
tone="default"
|
||
/>
|
||
<KPIStat
|
||
label="Pending"
|
||
value={isPending ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}
|
||
tone="warning"
|
||
/>
|
||
<KPIStat
|
||
label="Picked Up"
|
||
value={isPending ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}
|
||
tone="primary"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Filter Bar ────────────────────────────────────────────────────────────────
|
||
|
||
function OrdersFilterBar({
|
||
activeTab,
|
||
onActiveTabChange,
|
||
filteredCount,
|
||
pendingCount,
|
||
pickedUpCount,
|
||
search,
|
||
onSearchChange,
|
||
onSearchClear,
|
||
stops,
|
||
selectedStops,
|
||
showStopDropdown,
|
||
onToggleStopDropdown,
|
||
onToggleStop,
|
||
onClearStops,
|
||
}: {
|
||
activeTab: StatusTab;
|
||
onActiveTabChange: (value: StatusTab) => void;
|
||
filteredCount: number;
|
||
pendingCount: number;
|
||
pickedUpCount: number;
|
||
search: string;
|
||
onSearchChange: (value: string) => void;
|
||
onSearchClear: () => void;
|
||
stops: Stop[];
|
||
selectedStops: string[];
|
||
showStopDropdown: boolean;
|
||
onToggleStopDropdown: (value: boolean) => void;
|
||
onToggleStop: (stopId: string) => void;
|
||
onClearStops: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||
<AdminFilterTabs
|
||
activeTab={activeTab}
|
||
onTabChange={(value) => onActiveTabChange(value as StatusTab)}
|
||
tabs={[
|
||
{ value: "all", label: "All", count: filteredCount },
|
||
{ value: "pending", label: "Pending", count: pendingCount },
|
||
{ value: "picked_up", label: "Picked Up", count: pickedUpCount },
|
||
]}
|
||
size="md"
|
||
/>
|
||
|
||
<AdminSearchInput
|
||
placeholder="Search name, phone, or order #..."
|
||
value={search}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
onClear={onSearchClear}
|
||
showClear={true}
|
||
/>
|
||
|
||
<StopFilterDropdown
|
||
stops={stops}
|
||
selectedStops={selectedStops}
|
||
showStopDropdown={showStopDropdown}
|
||
onToggleStopDropdown={onToggleStopDropdown}
|
||
onToggleStop={onToggleStop}
|
||
onClearStops={onClearStops}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StopFilterDropdown({
|
||
stops,
|
||
selectedStops,
|
||
showStopDropdown,
|
||
onToggleStopDropdown,
|
||
onToggleStop,
|
||
onClearStops,
|
||
}: {
|
||
stops: Stop[];
|
||
selectedStops: string[];
|
||
showStopDropdown: boolean;
|
||
onToggleStopDropdown: (value: boolean) => void;
|
||
onToggleStop: (stopId: string) => void;
|
||
onClearStops: () => void;
|
||
}) {
|
||
return (
|
||
<div className="relative">
|
||
<button type="button"
|
||
onClick={() => onToggleStopDropdown(!showStopDropdown)}
|
||
className="flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors"
|
||
style={
|
||
selectedStops.length > 0
|
||
? {
|
||
borderColor: "var(--admin-accent)",
|
||
backgroundColor: "var(--admin-accent-light)",
|
||
color: "var(--admin-accent-text)",
|
||
}
|
||
: {
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
color: "var(--admin-text-secondary)",
|
||
}
|
||
}
|
||
>
|
||
<MapPinIcon />
|
||
<span>Stops</span>
|
||
{selectedStops.length > 0 && (
|
||
<span
|
||
className="rounded-full text-white px-1.5 py-0.5 text-xs font-semibold"
|
||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||
>
|
||
{selectedStops.length}
|
||
</span>
|
||
)}
|
||
<ChevronDownIcon />
|
||
</button>
|
||
|
||
{showStopDropdown && (
|
||
<>
|
||
<button type="button" aria-label="Close dropdown" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => onToggleStopDropdown(false)} />
|
||
<div
|
||
className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between border-b px-4 py-3"
|
||
style={{ borderColor: "var(--admin-border-light)" }}
|
||
>
|
||
<span
|
||
className="text-sm font-semibold"
|
||
style={{ color: "var(--admin-text-primary)" }}
|
||
>
|
||
Filter by Stop
|
||
</span>
|
||
<button type="button"
|
||
onClick={onClearStops}
|
||
className="text-xs font-medium"
|
||
style={{ color: "var(--admin-accent)" }}
|
||
>
|
||
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 cursor-pointer transition-colors"
|
||
style={{ color: "var(--admin-text-primary)" }}
|
||
htmlFor="fld-togglestopstopid-classnameh-4-w-4-rounde">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedStops.includes(stop.id)}
|
||
onChange={() => onToggleStop(stop.id)}
|
||
className="h-4 w-4 rounded"
|
||
style={{
|
||
borderColor: "var(--admin-border-strong)",
|
||
accentColor: "var(--admin-accent)",
|
||
}}
|
||
/>
|
||
<div className="flex-1">
|
||
<span
|
||
className="text-sm font-medium"
|
||
style={{ color: "var(--admin-text-primary)" }}
|
||
>
|
||
{stop.city}, {stop.state}
|
||
</span>
|
||
<span
|
||
className="ml-2 text-xs"
|
||
style={{ color: "var(--admin-text-muted)" }}
|
||
>
|
||
{formatDate(stop.date)}
|
||
</span>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Selected Stop Chips ────────────────────────────────────────────────────────
|
||
|
||
function SelectedStopChips({
|
||
stops,
|
||
selectedStops,
|
||
onToggleStop,
|
||
}: {
|
||
stops: Stop[];
|
||
selectedStops: string[];
|
||
onToggleStop: (stopId: string) => void;
|
||
}) {
|
||
if (selectedStops.length === 0) return null;
|
||
return (
|
||
<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 border px-3 py-1 text-xs font-medium"
|
||
style={{
|
||
backgroundColor: "var(--admin-accent-light)",
|
||
borderColor: "var(--admin-accent)",
|
||
color: "var(--admin-accent-text)",
|
||
}}
|
||
>
|
||
{stop.city}, {stop.state}
|
||
<button type="button"
|
||
onClick={() => onToggleStop(stopId)}
|
||
className="ml-1"
|
||
style={{ color: "var(--admin-accent-hover)" }}
|
||
aria-label={`Remove ${stop.city}, ${stop.state}`}
|
||
>
|
||
<XIcon />
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Bulk Actions Bar ──────────────────────────────────────────────────────────
|
||
|
||
function BulkActionsBar({
|
||
selectedCount,
|
||
bulkMarkingUp,
|
||
onClear,
|
||
onMarkAll,
|
||
}: {
|
||
selectedCount: number;
|
||
bulkMarkingUp: boolean;
|
||
onClear: () => void;
|
||
onMarkAll: () => void;
|
||
}) {
|
||
if (selectedCount === 0) return null;
|
||
return (
|
||
<div
|
||
className="mb-4 flex items-center justify-between rounded-xl border px-4 py-3"
|
||
style={{
|
||
borderColor: "var(--admin-accent)",
|
||
backgroundColor: "var(--admin-accent-light)",
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span
|
||
className="text-sm font-semibold"
|
||
style={{ color: "var(--admin-accent-text)" }}
|
||
>
|
||
{selectedCount} order{selectedCount !== 1 ? 's' : ''} selected
|
||
</span>
|
||
<button type="button"
|
||
onClick={onClear}
|
||
className="text-xs"
|
||
style={{ color: "var(--admin-text-muted)" }}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
<AdminButton
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={onMarkAll}
|
||
isLoading={bulkMarkingUp}
|
||
icon={<CheckIcon />}
|
||
>
|
||
Mark All as Picked Up
|
||
</AdminButton>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Orders Table Section ──────────────────────────────────────────────────────
|
||
|
||
function OrdersTableSection({
|
||
isPending,
|
||
ordersCount,
|
||
paginatedOrders,
|
||
selectedOrders,
|
||
pickingUp,
|
||
onToggleSelectAll,
|
||
onToggleOrderSelection,
|
||
onMarkPickup,
|
||
}: {
|
||
isPending: boolean;
|
||
ordersCount: number;
|
||
paginatedOrders: Order[];
|
||
selectedOrders: Set<string>;
|
||
pickingUp: string | null;
|
||
onToggleSelectAll: () => void;
|
||
onToggleOrderSelection: (orderId: string) => void;
|
||
onMarkPickup: (orderId: string) => void;
|
||
}) {
|
||
if (isPending) {
|
||
return (
|
||
<div
|
||
className="rounded-xl border p-6"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
}}
|
||
>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
if (paginatedOrders.length === 0) {
|
||
return (
|
||
<div
|
||
className="rounded-xl border"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
}}
|
||
>
|
||
<EmptyState
|
||
icon={<Package className="h-9 w-9" strokeWidth={1.25} />}
|
||
title={ordersCount === 0 ? "No orders yet" : "No orders match your filters"}
|
||
description={
|
||
ordersCount === 0
|
||
? "Create your first order to get started."
|
||
: "Try adjusting your filters or clearing your search."
|
||
}
|
||
action={
|
||
ordersCount === 0
|
||
? { label: "Create your first order", href: "/admin/orders?new=true" }
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<OrdersTable
|
||
paginatedOrders={paginatedOrders}
|
||
selectedOrders={selectedOrders}
|
||
pickingUp={pickingUp}
|
||
onToggleSelectAll={onToggleSelectAll}
|
||
onToggleOrderSelection={onToggleOrderSelection}
|
||
onMarkPickup={onMarkPickup}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function OrdersTable({
|
||
paginatedOrders,
|
||
selectedOrders,
|
||
pickingUp,
|
||
onToggleSelectAll,
|
||
onToggleOrderSelection,
|
||
onMarkPickup,
|
||
}: {
|
||
paginatedOrders: Order[];
|
||
selectedOrders: Set<string>;
|
||
pickingUp: string | null;
|
||
onToggleSelectAll: () => void;
|
||
onToggleOrderSelection: (orderId: string) => void;
|
||
onMarkPickup: (orderId: string) => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="overflow-x-auto rounded-xl border"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
}}
|
||
>
|
||
<table className="w-full text-sm min-w-[700px]">
|
||
<thead style={{ backgroundColor: "var(--admin-bg)" }}>
|
||
<tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
|
||
<th className="w-10 px-4 py-3">
|
||
<input id="fld-togglestopstopid-classnameh-4-w-4-rounde" aria-label="Checkbox"
|
||
type="checkbox"
|
||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||
onChange={onToggleSelectAll}
|
||
className="h-4 w-4 rounded cursor-pointer"
|
||
style={{
|
||
borderColor: "var(--admin-border-strong)",
|
||
accentColor: "var(--admin-accent)",
|
||
}}
|
||
/>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-xs" style={{ color: "var(--admin-text-muted)" }}>Order</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-xs" style={{ color: "var(--admin-text-muted)" }}>Customer</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-xs hidden md:table-cell" style={{ color: "var(--admin-text-muted)" }}>Stop</th>
|
||
<th className="text-left px-4 py-3 font-semibold text-xs hidden lg:table-cell" style={{ color: "var(--admin-text-muted)" }}>Date</th>
|
||
<th className="text-center px-4 py-3 font-semibold text-xs" style={{ color: "var(--admin-text-muted)" }}>Items</th>
|
||
<th className="text-right px-4 py-3 font-semibold text-xs" style={{ color: "var(--admin-text-muted)" }}>Total</th>
|
||
<th className="text-center px-4 py-3 font-semibold text-xs" style={{ color: "var(--admin-text-muted)" }}>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody style={{ borderColor: "var(--admin-border)" }}>
|
||
{paginatedOrders.map((order) => (
|
||
<OrderRow
|
||
key={order.id}
|
||
order={order}
|
||
isSelected={selectedOrders.has(order.id)}
|
||
isPickingUp={pickingUp === order.id}
|
||
onToggleSelect={() => onToggleOrderSelection(order.id)}
|
||
onMarkPickup={() => onMarkPickup(order.id)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OrderRow({
|
||
order,
|
||
isSelected,
|
||
isPickingUp,
|
||
onToggleSelect,
|
||
onMarkPickup,
|
||
}: {
|
||
order: Order;
|
||
isSelected: boolean;
|
||
isPickingUp: boolean;
|
||
onToggleSelect: () => void;
|
||
onMarkPickup: () => void;
|
||
}) {
|
||
return (
|
||
<tr
|
||
className="transition-colors"
|
||
style={{ borderTop: "1px solid var(--admin-border-light)" }}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<input aria-label="Checkbox"
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={onToggleSelect}
|
||
className="h-4 w-4 rounded cursor-pointer"
|
||
style={{
|
||
borderColor: "var(--admin-border-strong)",
|
||
accentColor: "var(--admin-accent)",
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Link
|
||
href={`/admin/orders/${order.id}`}
|
||
className="font-mono text-xs"
|
||
style={{ color: "var(--admin-accent)" }}
|
||
>
|
||
{shortId(order.id)}
|
||
</Link>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="font-medium" style={{ color: "var(--admin-text-primary)" }}>
|
||
{order.customer_name}
|
||
</div>
|
||
{order.customer_phone && (
|
||
<div className="font-mono text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||
{order.customer_phone}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 hidden md:table-cell">
|
||
{order.stops ? (
|
||
<span className="text-sm" style={{ color: "var(--admin-text-primary)" }}>
|
||
{order.stops.city}, {order.stops.state}
|
||
</span>
|
||
) : (
|
||
<span style={{ color: "var(--admin-text-muted)" }}>—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 hidden lg:table-cell">
|
||
<span className="text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||
{formatDate(order.created_at)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<span className="font-mono text-sm" style={{ color: "var(--admin-text-secondary)" }}>
|
||
{order.order_items?.length ?? 0}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<span className="font-mono font-semibold" style={{ color: "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 tone="success" dot>Picked Up</AdminBadge>
|
||
) : (
|
||
<AdminBadge tone="warning" dot>Pending</AdminBadge>
|
||
)}
|
||
{order.payment_processor === "square" && (
|
||
<AdminBadge tone="info">Square</AdminBadge>
|
||
)}
|
||
{!order.pickup_complete && (
|
||
<AdminButton
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onMarkPickup}
|
||
disabled={isPickingUp}
|
||
isLoading={isPickingUp}
|
||
>
|
||
{isPickingUp ? "..." : "Pick Up"}
|
||
</AdminButton>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ── Pagination ────────────────────────────────────────────────────────────────
|
||
|
||
function OrdersPagination({
|
||
page,
|
||
totalPages,
|
||
totalCount,
|
||
onPageChange,
|
||
}: {
|
||
page: number;
|
||
totalPages: number;
|
||
totalCount: number;
|
||
onPageChange: (p: number) => void;
|
||
}) {
|
||
if (totalPages <= 1) return null;
|
||
return (
|
||
<div
|
||
className="flex items-center justify-between mt-4 pt-4 border-t"
|
||
style={{ borderColor: "var(--admin-border)" }}
|
||
>
|
||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, totalCount)} of {totalCount}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<AdminIconButton
|
||
variant="secondary"
|
||
size="sm"
|
||
label="Previous page"
|
||
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||
disabled={page === 0}
|
||
className="!rounded-lg"
|
||
>
|
||
<ChevronLeftIcon />
|
||
</AdminIconButton>
|
||
<span className="px-3 text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>
|
||
{page + 1} / {totalPages}
|
||
</span>
|
||
<AdminIconButton
|
||
variant="secondary"
|
||
size="sm"
|
||
label="Next page"
|
||
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
|
||
disabled={page >= totalPages - 1}
|
||
className="!rounded-lg"
|
||
>
|
||
<ChevronRightIcon />
|
||
</AdminIconButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── New Order Modal ───────────────────────────────────────────────────────────
|
||
|
||
function NewOrderModal({
|
||
stops,
|
||
products,
|
||
customerName,
|
||
customerEmail,
|
||
customerPhone,
|
||
stopId,
|
||
items,
|
||
submitting,
|
||
error,
|
||
total,
|
||
onClose,
|
||
onSetCustomerName,
|
||
onSetCustomerEmail,
|
||
onSetCustomerPhone,
|
||
onSetStopId,
|
||
onAddItem,
|
||
onUpdateItem,
|
||
onRemoveItem,
|
||
onSubmit,
|
||
}: {
|
||
stops: Stop[];
|
||
products: Product[];
|
||
customerName: string;
|
||
customerEmail: string;
|
||
customerPhone: string;
|
||
stopId: string;
|
||
items: NewOrderItem[];
|
||
submitting: boolean;
|
||
error: string | null;
|
||
total: number;
|
||
onClose: () => void;
|
||
onSetCustomerName: (value: string) => void;
|
||
onSetCustomerEmail: (value: string) => void;
|
||
onSetCustomerPhone: (value: string) => void;
|
||
onSetStopId: (value: string) => void;
|
||
onAddItem: (productId: string) => void;
|
||
onUpdateItem: (index: number, patch: Partial<NewOrderItem>) => void;
|
||
onRemoveItem: (index: number) => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(26, 24, 20, 0.4)" }}>
|
||
<div
|
||
className="w-full max-w-2xl rounded-2xl border shadow-2xl"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between border-b px-6 py-4"
|
||
style={{ borderColor: "var(--admin-border)" }}
|
||
>
|
||
<div>
|
||
<h3 className="text-lg font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||
New Order (Admin)
|
||
</h3>
|
||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||
Manual entry for testing / phone orders
|
||
</p>
|
||
</div>
|
||
<button type="button"
|
||
onClick={onClose}
|
||
className="text-2xl leading-none"
|
||
style={{ color: "var(--admin-text-muted)" }}
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
|
||
{error && (
|
||
<div
|
||
className="rounded-lg border px-4 py-2 text-sm"
|
||
style={{
|
||
backgroundColor: "var(--admin-danger-soft)",
|
||
borderColor: "var(--admin-danger-soft)",
|
||
color: "var(--admin-danger)",
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<AdminInput label="Customer Name" required>
|
||
<AdminTextInput
|
||
value={customerName}
|
||
onChange={(e) => onSetCustomerName(e.target.value)}
|
||
placeholder="Jane Doe"
|
||
/>
|
||
</AdminInput>
|
||
<AdminInput label="Email">
|
||
<AdminTextInput
|
||
value={customerEmail}
|
||
onChange={(e) => onSetCustomerEmail(e.target.value)}
|
||
placeholder="jane@example.com"
|
||
type="email"
|
||
/>
|
||
</AdminInput>
|
||
<AdminInput label="Phone">
|
||
<AdminTextInput
|
||
value={customerPhone}
|
||
onChange={(e) => onSetCustomerPhone(e.target.value)}
|
||
placeholder="(555) 123-4567"
|
||
/>
|
||
</AdminInput>
|
||
</div>
|
||
|
||
<AdminInput label="Pickup Stop (leave blank for ship-only)">
|
||
<AdminSelect
|
||
value={stopId}
|
||
onChange={(e) => onSetStopId(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>
|
||
|
||
<NewOrderItemsEditor
|
||
products={products}
|
||
items={items}
|
||
total={total}
|
||
onAdd={onAddItem}
|
||
onUpdate={onUpdateItem}
|
||
onRemove={onRemoveItem}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="flex items-center justify-end gap-3 border-t px-6 py-4 rounded-b-2xl"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-bg-subtle)",
|
||
}}
|
||
>
|
||
<AdminButton variant="secondary" onClick={onClose} disabled={submitting}>
|
||
Cancel
|
||
</AdminButton>
|
||
<AdminButton onClick={onSubmit} disabled={submitting || items.length === 0}>
|
||
{submitting ? "Creating..." : `Create Order — $${total.toFixed(2)}`}
|
||
</AdminButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NewOrderItemsEditor({
|
||
products,
|
||
items,
|
||
total,
|
||
onAdd,
|
||
onUpdate,
|
||
onRemove,
|
||
}: {
|
||
products: Product[];
|
||
items: NewOrderItem[];
|
||
total: number;
|
||
onAdd: (productId: string) => void;
|
||
onUpdate: (index: number, patch: Partial<NewOrderItem>) => void;
|
||
onRemove: (index: number) => void;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="block text-xs font-semibold" style={{ color: "var(--admin-text-secondary)" }} htmlFor="fld-items">
|
||
Items
|
||
</label>
|
||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||
Total: ${total.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
|
||
{products.length > 0 ? (
|
||
<div className="mb-3">
|
||
<AdminSelect
|
||
value=""
|
||
onChange={(e) => {
|
||
if (e.target.value) onAdd(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 mb-2" style={{ color: "var(--admin-warning)" }}>
|
||
No products loaded for this brand. You can still enter custom items if the action supports it.
|
||
</p>
|
||
)}
|
||
|
||
{items.length === 0 && (
|
||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||
No items yet. Use the selector above.
|
||
</p>
|
||
)}
|
||
|
||
{items.length > 0 && (
|
||
<div
|
||
className="space-y-2 border rounded-xl p-3"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-bg)",
|
||
}}
|
||
>
|
||
{items.map((item, idx) => {
|
||
const prod = products.find((p) => p.id === item.product_id);
|
||
return (
|
||
<div key={`${item.product_id}-${item.fulfillment}-${idx}`} className="grid grid-cols-12 gap-2 items-center text-sm">
|
||
<div className="col-span-5 font-medium truncate" style={{ color: "var(--admin-text-primary)" }}>
|
||
{prod?.name ?? item.product_id}
|
||
</div>
|
||
<div className="col-span-2">
|
||
<input id="fld-items" aria-label="Number"
|
||
type="number"
|
||
min={1}
|
||
value={item.quantity}
|
||
onChange={(e) => onUpdate(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
|
||
className="w-full rounded border px-2 py-1 text-sm"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
color: "var(--admin-text-primary)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<input aria-label="Number"
|
||
type="number"
|
||
step="0.01"
|
||
value={item.price}
|
||
onChange={(e) => onUpdate(idx, { price: parseFloat(e.target.value) || 0 })}
|
||
className="w-full rounded border px-2 py-1 text-sm"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
color: "var(--admin-text-primary)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<select aria-label="Select"
|
||
value={item.fulfillment}
|
||
onChange={(e) => onUpdate(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
|
||
className="w-full rounded border px-2 py-1 text-sm"
|
||
style={{
|
||
borderColor: "var(--admin-border)",
|
||
backgroundColor: "var(--admin-card-bg)",
|
||
color: "var(--admin-text-primary)",
|
||
}}
|
||
>
|
||
<option value="pickup">pickup</option>
|
||
<option value="ship">ship</option>
|
||
</select>
|
||
</div>
|
||
<div className="col-span-1 text-right">
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(idx)}
|
||
className="text-xs hover:underline"
|
||
style={{ color: "var(--admin-danger)" }}
|
||
>
|
||
remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|