diff --git a/app/api/admin/orders-by-timeslot/route.ts b/app/api/admin/orders-by-timeslot/route.ts new file mode 100644 index 0000000..3ad7eb1 --- /dev/null +++ b/app/api/admin/orders-by-timeslot/route.ts @@ -0,0 +1,220 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getPayloadClient } from "../../../../lib/payload"; + +/** + * GET /api/admin/orders-by-timeslot + * + * Admin-only endpoint that returns timeslots with their orders pre-grouped, + * replacing the N+1 query pattern where the client fetched each timeslot's + * orders individually. + * + * Query params: + * - `filter` ("upcoming" | "all", default "upcoming") — "upcoming" returns + * timeslots with dates from today through the next 7 days. + * + * Returns: + * - `groups[]` — timeslots with their orders already attached + * - Each group has `timeslot` and `orders[]` + * - A synthetic "unassigned" group is prepended for PAID orders without a slot + */ +export async function GET(request: NextRequest) { + try { + const payload = await getPayloadClient(); + + // Verify admin auth + const { user } = await payload.auth({ headers: request.headers }); + if (!user) { + return NextResponse.json( + { error: "Admin authentication required." }, + { status: 401 }, + ); + } + + const { searchParams } = new URL(request.url); + const filter = searchParams.get("filter") === "all" ? "all" : "upcoming"; + + // ── 1. Fetch all active timeslots, filter by date in JS ────────── + // The date field is stored as a plain text "YYYY-MM-DD" string. + // We fetch all active timeslots and filter in JS to handle any + // date strings that might contain a "T" suffix consistently. + const allTimeslotResult = await payload.find({ + collection: "timeslots", + where: { isActive: { equals: true } }, + sort: "date", + limit: 0, + pagination: false, + depth: 0, + }); + + let timeslots = allTimeslotResult.docs; + + if (filter === "upcoming") { + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + const nextWeekStr = `${nextWeek.getFullYear()}-${String(nextWeek.getMonth() + 1).padStart(2, "0")}-${String(nextWeek.getDate()).padStart(2, "0")}`; + + timeslots = timeslots.filter((slot) => { + const d = + typeof slot.date === "string" + ? slot.date.includes("T") + ? slot.date.split("T")[0] + : slot.date + : ""; + return d >= todayStr && d <= nextWeekStr; + }); + } + + const timeslotIds = timeslots.map((t) => String(t.id)); + + // ── 2. Fetch orders in two parallel queries ────────────────────── + const [assignedResult, unassignedResult] = await Promise.all([ + // All orders assigned to any of the fetched timeslots + payload.find({ + collection: "orders", + where: + timeslotIds.length > 0 + ? { pickupTimeslot: { in: timeslotIds } } + : { id: { equals: "__none__" } }, + sort: "-createdAt", + limit: 0, + pagination: false, + depth: 1, + }), + // Paid orders with no timeslot selected + payload.find({ + collection: "orders", + where: { + and: [ + { status: { equals: "PAID" } }, + { + or: [ + { pickupTimeslot: { exists: false } }, + { pickupTimeslot: { equals: null } }, + ], + }, + ], + }, + sort: "-createdAt", + limit: 0, + pagination: false, + depth: 1, + }), + ]); + + // ── 3. Group assigned orders by timeslot ID ────────────────────── + type OrderDoc = (typeof assignedResult.docs)[number]; + const ordersBySlot = new Map(); + + for (const order of assignedResult.docs) { + const rawSlot = order.pickupTimeslot; + const slotId = rawSlot + ? typeof rawSlot === "object" + ? String((rawSlot as { id: string | number }).id) + : String(rawSlot) + : null; + + if (slotId) { + const list = ordersBySlot.get(slotId); + if (list) { + list.push(order); + } else { + ordersBySlot.set(slotId, [order]); + } + } + } + + // ── 4. Build response ──────────────────────────────────────────── + interface OrderResponse { + id: string | number; + orderNumber: string; + status: string; + paymentMethod: string | null; + bankTransferVerified: boolean | null; + pricing: { total: number }; + files: { fileName: string; copies: number; colorMode?: string }[]; + customer: { name: string; email: string } | string; + pickedUpAt: string | null; + } + + function mapOrder(order: OrderDoc): OrderResponse { + return { + id: order.id, + orderNumber: order.orderNumber, + status: order.status, + paymentMethod: (order.paymentMethod as string) || null, + bankTransferVerified: (order.bankTransferVerified as boolean) ?? null, + pricing: order.pricing as { total: number }, + files: (order.files || []).map((f: Record) => ({ + fileName: f.fileName as string, + copies: f.copies as number, + colorMode: (f.colorMode as string) || undefined, + })), + customer: + typeof order.customer === "object" && order.customer + ? { + name: (order.customer as { name?: string }).name || "—", + email: (order.customer as { email?: string }).email || "—", + } + : String(order.customer), + pickedUpAt: (order.pickedUpAt as string) || null, + }; + } + + const groups: { + timeslot: { + id: string; + date: string; + startTime: string; + endTime: string; + label: string; + }; + orders: OrderResponse[]; + }[] = []; + + // Unassigned group first + if (unassignedResult.docs.length > 0) { + groups.push({ + timeslot: { + id: "unassigned", + date: "", + startTime: "", + endTime: "", + label: "⚠️ Paid — No Pickup Time Selected", + }, + orders: unassignedResult.docs.map(mapOrder), + }); + } + + // Timeslot groups (only include slots that have orders) + for (const slot of timeslots) { + const slotOrders = ordersBySlot.get(String(slot.id)); + if (slotOrders && slotOrders.length > 0) { + groups.push({ + timeslot: { + id: String(slot.id), + date: slot.date as string, + startTime: slot.startTime as string, + endTime: slot.endTime as string, + label: (slot.label as string) || "", + }, + orders: slotOrders.map(mapOrder), + }); + } + } + + return NextResponse.json({ success: true, groups }); + } catch (error) { + console.error("Error fetching orders by timeslot:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to fetch orders by timeslot.", + }, + { status: 500 }, + ); + } +} diff --git a/components/admin/OrdersByTimeslotView.tsx b/components/admin/OrdersByTimeslotView.tsx index 8490f38..2eb94f3 100644 --- a/components/admin/OrdersByTimeslotView.tsx +++ b/components/admin/OrdersByTimeslotView.tsx @@ -52,69 +52,14 @@ export default function OrdersByTimeslotView() { setLoading(true); setError(null); try { - // Fetch active timeslots sorted by date - const timeslotRes = await fetch( - `/api/timeslots?where[isActive][equals]=true&limit=500&sort=date&depth=0`, - ); - if (!timeslotRes.ok) throw new Error("Failed to fetch timeslots."); - const timeslotData = await timeslotRes.json(); - const allTimeslots: TimeslotData[] = timeslotData.docs || []; - - // Client-side date filtering for "upcoming" (today + 7 days) - const todayStr = new Date().toLocaleDateString("en-CA"); // YYYY-MM-DD - const nextWeek = new Date(); - nextWeek.setDate(nextWeek.getDate() + 7); - const nextWeekStr = nextWeek.toLocaleDateString("en-CA"); - - const timeslots = - filter === "upcoming" - ? allTimeslots.filter((slot) => { - const d = slot.date.includes("T") - ? slot.date.split("T")[0] - : slot.date; - return d >= todayStr && d <= nextWeekStr; - }) - : allTimeslots; - - // For each timeslot, fetch orders assigned to it - const results: TimeslotWithOrders[] = []; - - for (const slot of timeslots) { - const orderRes = await fetch( - `/api/orders?where[pickupTimeslot][equals]=${slot.id}&depth=1&limit=50&sort=-createdAt`, - ); - if (!orderRes.ok) continue; - const orderData = await orderRes.json(); - - if (orderData.docs && orderData.docs.length > 0) { - results.push({ - timeslot: slot, - orders: orderData.docs, - }); - } - } - - // Also add a "No timeslot" group for paid orders without a slot - const unassignedRes = await fetch( - `/api/orders?where[status][in]=PAID&where[pickupTimeslot][exists]=false&depth=1&limit=50`, - ); - if (unassignedRes.ok) { - const unassignedData = await unassignedRes.json(); - if (unassignedData.docs && unassignedData.docs.length > 0) { - results.unshift({ - timeslot: { - id: "unassigned", - date: "", - startTime: "", - endTime: "", - label: "⚠️ Paid — No Pickup Time Selected", - }, - orders: unassignedData.docs, - }); - } + // Single API call replaces N+2 sequential requests + const res = await fetch(`/api/admin/orders-by-timeslot?filter=${filter}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || "Failed to fetch orders by timeslot."); } - - setData(results); + const json = await res.json(); + setData(json.groups || []); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load data."); } finally { diff --git a/components/ordercontainer/OrderSteps.tsx b/components/ordercontainer/OrderSteps.tsx index afc15d1..32c76fd 100644 --- a/components/ordercontainer/OrderSteps.tsx +++ b/components/ordercontainer/OrderSteps.tsx @@ -440,20 +440,34 @@ export default function OrderSteps({ // ── Pickup step ──────────────────────────────────────────────── if (step === OrderStep.PICKUP) { + const hasExistingTimeslot = !!orderDetails?.pickupTimeslot; + return ( - 📍 Select Pickup Time + {hasExistingTimeslot + ? "📍 Change Pickup Time" + : "📍 Select Pickup Time"} - Order {orderNumber} has been paid. Choose when - you'd like to collect it. + {hasExistingTimeslot ? ( + <> + Order {orderNumber} already has a pickup time. + Select a new timeslot below. + + ) : ( + <> + Order {orderNumber} has been paid. Choose when + you'd like to collect it. + + )} onPickupConfirmed()} onCancel={() => router.push("/my-orders")} /> diff --git a/components/pickup/TimeslotSelector.tsx b/components/pickup/TimeslotSelector.tsx index 58da5e9..05b6c05 100644 --- a/components/pickup/TimeslotSelector.tsx +++ b/components/pickup/TimeslotSelector.tsx @@ -44,8 +44,21 @@ interface TimeslotResponse { hasMore: boolean; } +interface CurrentTimeslot { + date: string; + startTime: string; + endTime: string; + label?: string; + pickupInstructionProfile?: + | { id: string; name: string; shortSummary?: string } + | string + | null; +} + interface TimeslotSelectorProps { orderId: string; + /** If provided, indicates the user is changing an existing timeslot. */ + currentTimeslot?: CurrentTimeslot | null; onTimeslotSelected: ( timeslotId: string, pickupInstructions?: unknown[], @@ -106,9 +119,11 @@ async function fetchTimeslots(page: number): Promise { */ export function TimeslotSelector({ orderId, + currentTimeslot, onTimeslotSelected, onCancel, }: TimeslotSelectorProps) { + const isChanging = !!currentTimeslot; const [selectedId, setSelectedId] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -204,9 +219,41 @@ export function TimeslotSelector({ transition="opacity 0.15s ease" > - Select a Pickup Timeslot + {isChanging ? "Change Pickup Timeslot" : "Select a Pickup Timeslot"} + {/* Show current timeslot when changing */} + {isChanging && currentTimeslot && ( + + + CURRENT TIMESLOT + + + {currentTimeslot.startTime} – {currentTimeslot.endTime} + {currentTimeslot.label ? ` · ${currentTimeslot.label}` : ""} + + {typeof currentTimeslot.pickupInstructionProfile === "object" && + currentTimeslot.pickupInstructionProfile && ( + + 📍 {currentTimeslot.pickupInstructionProfile.name} + {currentTimeslot.pickupInstructionProfile.shortSummary && ( + + {" "} + — {currentTimeslot.pickupInstructionProfile.shortSummary} + + )} + + )} + + )} + {submitError && ( @@ -328,9 +375,9 @@ export function TimeslotSelector({ onClick={handleSubmit} isDisabled={!selectedId || submitting} isLoading={submitting} - loadingText="Confirming..." + loadingText={isChanging ? "Updating..." : "Confirming..."} > - Confirm Timeslot + {isChanging ? "Update Timeslot" : "Confirm Timeslot"}