Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions app/api/admin/orders-by-timeslot/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, OrderDoc[]>();

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<string, unknown>) => ({
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 },
);
}
}
69 changes: 7 additions & 62 deletions components/admin/OrdersByTimeslotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 17 additions & 3 deletions components/ordercontainer/OrderSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,20 +440,34 @@ export default function OrderSteps({
// ── Pickup step ────────────────────────────────────────────────

if (step === OrderStep.PICKUP) {
const hasExistingTimeslot = !!orderDetails?.pickupTimeslot;

return (
<Box {...containerProps}>
<StepIndicator />
<Heading size="lg" mb={2}>
📍 Select Pickup Time
{hasExistingTimeslot
? "📍 Change Pickup Time"
: "📍 Select Pickup Time"}
</Heading>
<Text color="gray.600" mb={4}>
Order <strong>{orderNumber}</strong> has been paid. Choose when
you&apos;d like to collect it.
{hasExistingTimeslot ? (
<>
Order <strong>{orderNumber}</strong> already has a pickup time.
Select a new timeslot below.
</>
) : (
<>
Order <strong>{orderNumber}</strong> has been paid. Choose when
you&apos;d like to collect it.
</>
)}
</Text>
<OrderSummary />
<Divider mb={6} />
<TimeslotSelector
orderId={orderId}
currentTimeslot={orderDetails?.pickupTimeslot ?? null}
onTimeslotSelected={() => onPickupConfirmed()}
onCancel={() => router.push("/my-orders")}
/>
Expand Down
Loading
Loading