From b4076064c4f2f56d428a988a15f2c28b70917576 Mon Sep 17 00:00:00 2001 From: Benson Cho <100653148+choden-dev@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:34:41 +1200 Subject: [PATCH 1/2] feat: add file download buttons to Orders by Timeslot dashboard Previously admins had to click into each individual order to view and download files for printing. This adds: - Per-file 'Open' button next to each file name in the dashboard table - 'Download All Files' button in each timeslot header to open all files for that timeslot at once API changes: - orders-by-timeslot endpoint now includes stagingKey and permanentKey in the file data so the dashboard can generate presigned download URLs The file key resolution logic (permanent vs staging) mirrors the existing OrderFilesViewer component. --- app/api/admin/orders-by-timeslot/route.ts | 10 +- components/admin/OrdersByTimeslotView.tsx | 207 ++++++++++++++++++++-- 2 files changed, 198 insertions(+), 19 deletions(-) diff --git a/app/api/admin/orders-by-timeslot/route.ts b/app/api/admin/orders-by-timeslot/route.ts index 3ad7eb1..c1a5ed2 100644 --- a/app/api/admin/orders-by-timeslot/route.ts +++ b/app/api/admin/orders-by-timeslot/route.ts @@ -133,7 +133,13 @@ export async function GET(request: NextRequest) { paymentMethod: string | null; bankTransferVerified: boolean | null; pricing: { total: number }; - files: { fileName: string; copies: number; colorMode?: string }[]; + files: { + fileName: string; + copies: number; + colorMode?: string; + stagingKey?: string; + permanentKey?: string; + }[]; customer: { name: string; email: string } | string; pickedUpAt: string | null; } @@ -150,6 +156,8 @@ export async function GET(request: NextRequest) { fileName: f.fileName as string, copies: f.copies as number, colorMode: (f.colorMode as string) || undefined, + stagingKey: (f.stagingKey as string) || undefined, + permanentKey: (f.permanentKey as string) || undefined, })), customer: typeof order.customer === "object" && order.customer diff --git a/components/admin/OrdersByTimeslotView.tsx b/components/admin/OrdersByTimeslotView.tsx index 2eb94f3..026ebac 100644 --- a/components/admin/OrdersByTimeslotView.tsx +++ b/components/admin/OrdersByTimeslotView.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { OrderStatus } from "../../types/orderStatus"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { OrderStatusValue } from "../../types/orderStatus"; +import { OrderStatus, PAID_STATUSES } from "../../types/orderStatus"; import BackToDashboard from "./BackToDashboard"; interface TimeslotData { @@ -19,7 +20,13 @@ interface OrderData { paymentMethod: string | null; bankTransferVerified: boolean | null; pricing: { total: number }; - files: { fileName: string; copies: number; colorMode?: string }[]; + files: { + fileName: string; + copies: number; + colorMode?: string; + stagingKey?: string; + permanentKey?: string; + }[]; customer: { name: string; email: string } | string; pickedUpAt: string | null; } @@ -126,6 +133,113 @@ export default function OrdersByTimeslotView() { return { name: "—", email: "—" }; }; + /** Resolve the correct R2 key for a file based on order payment status */ + const getFileKey = useCallback( + ( + file: OrderData["files"][number], + orderStatus: string, + ): { key: string; staging: boolean } | null => { + const isPaid = PAID_STATUSES.includes(orderStatus as OrderStatusValue); + if (isPaid && file.permanentKey) { + return { key: file.permanentKey, staging: false }; + } + if (file.stagingKey) { + return { key: file.stagingKey, staging: true }; + } + return null; + }, + [], + ); + + /** Fetch a presigned URL and open it in a new tab */ + const handleDownloadFile = useCallback( + async (file: OrderData["files"][number], orderStatus: string) => { + const resolved = getFileKey(file, orderStatus); + if (!resolved) return; + + try { + const params = new URLSearchParams({ + key: resolved.key, + staging: resolved.staging.toString(), + }); + const res = await fetch(`/api/admin/file-url?${params}`); + if (!res.ok) throw new Error("Failed to get file URL"); + const { url } = await res.json(); + window.open(url, "_blank"); + } catch { + window.alert(`Failed to open file: ${file.fileName}`); + } + }, + [getFileKey], + ); + + /** Download all files for all orders in a timeslot group sequentially */ + const downloadingRef = useRef(false); + const handleDownloadAllFiles = useCallback(async (orders: OrderData[]) => { + if (downloadingRef.current) return; + downloadingRef.current = true; + + try { + // Collect all files with their resolved keys + const filesToDownload: { + key: string; + staging: boolean; + fileName: string; + }[] = []; + for (const order of orders) { + for (const file of order.files || []) { + const resolved = getFileKey(file, order.status); + if (resolved) { + filesToDownload.push({ ...resolved, fileName: file.fileName }); + } + } + } + + if (filesToDownload.length === 0) { + window.alert("No downloadable files found."); + return; + } + + // Fetch all presigned URLs in parallel + const urlResults = await Promise.all( + filesToDownload.map(async ({ key, staging, fileName }) => { + try { + const params = new URLSearchParams({ + key, + staging: staging.toString(), + }); + const res = await fetch(`/api/admin/file-url?${params}`); + if (!res.ok) return null; + const { url } = await res.json(); + return { url, fileName }; + } catch { + return null; + } + }), + ); + + // Open each file in a new tab with a small delay to avoid popup blockers + const validUrls = urlResults.filter(Boolean) as { + url: string; + fileName: string; + }[]; + for (let i = 0; i < validUrls.length; i++) { + window.open(validUrls[i].url, "_blank"); + if (i < validUrls.length - 1) { + await new Promise((r) => setTimeout(r, 300)); + } + } + + if (validUrls.length < filesToDownload.length) { + window.alert( + `Opened ${validUrls.length} of ${filesToDownload.length} files. Some files could not be loaded.`, + ); + } + } finally { + downloadingRef.current = false; + } + }, [getFileKey]); + return (
@@ -285,16 +399,40 @@ export default function OrdersByTimeslotView() { )}
- - {orders.length} order{orders.length !== 1 ? "s" : ""} - + + {orders.length} order{orders.length !== 1 ? "s" : ""} + + + {/* Orders table */} @@ -393,14 +531,47 @@ export default function OrdersByTimeslotView() { - {order.files?.map((f) => ( -
- {f.fileName} ×{f.copies} -
- ))} + {order.files?.map((f) => { + const hasKey = !!(f.stagingKey || f.permanentKey); + return ( +
+ + {f.fileName} ×{f.copies} + + {hasKey && ( + + )} +
+ ); + })} Date: Sun, 26 Apr 2026 11:35:37 +1200 Subject: [PATCH 2/2] feat: improve file download handling in Orders by Timeslot dashboard --- components/admin/OrdersByTimeslotView.tsx | 113 +++++++++++----------- 1 file changed, 58 insertions(+), 55 deletions(-) diff --git a/components/admin/OrdersByTimeslotView.tsx b/components/admin/OrdersByTimeslotView.tsx index 026ebac..6d18572 100644 --- a/components/admin/OrdersByTimeslotView.tsx +++ b/components/admin/OrdersByTimeslotView.tsx @@ -175,70 +175,73 @@ export default function OrdersByTimeslotView() { /** Download all files for all orders in a timeslot group sequentially */ const downloadingRef = useRef(false); - const handleDownloadAllFiles = useCallback(async (orders: OrderData[]) => { - if (downloadingRef.current) return; - downloadingRef.current = true; + const handleDownloadAllFiles = useCallback( + async (orders: OrderData[]) => { + if (downloadingRef.current) return; + downloadingRef.current = true; - try { - // Collect all files with their resolved keys - const filesToDownload: { - key: string; - staging: boolean; - fileName: string; - }[] = []; - for (const order of orders) { - for (const file of order.files || []) { - const resolved = getFileKey(file, order.status); - if (resolved) { - filesToDownload.push({ ...resolved, fileName: file.fileName }); + try { + // Collect all files with their resolved keys + const filesToDownload: { + key: string; + staging: boolean; + fileName: string; + }[] = []; + for (const order of orders) { + for (const file of order.files || []) { + const resolved = getFileKey(file, order.status); + if (resolved) { + filesToDownload.push({ ...resolved, fileName: file.fileName }); + } } } - } - if (filesToDownload.length === 0) { - window.alert("No downloadable files found."); - return; - } + if (filesToDownload.length === 0) { + window.alert("No downloadable files found."); + return; + } - // Fetch all presigned URLs in parallel - const urlResults = await Promise.all( - filesToDownload.map(async ({ key, staging, fileName }) => { - try { - const params = new URLSearchParams({ - key, - staging: staging.toString(), - }); - const res = await fetch(`/api/admin/file-url?${params}`); - if (!res.ok) return null; - const { url } = await res.json(); - return { url, fileName }; - } catch { - return null; - } - }), - ); + // Fetch all presigned URLs in parallel + const urlResults = await Promise.all( + filesToDownload.map(async ({ key, staging, fileName }) => { + try { + const params = new URLSearchParams({ + key, + staging: staging.toString(), + }); + const res = await fetch(`/api/admin/file-url?${params}`); + if (!res.ok) return null; + const { url } = await res.json(); + return { url, fileName }; + } catch { + return null; + } + }), + ); - // Open each file in a new tab with a small delay to avoid popup blockers - const validUrls = urlResults.filter(Boolean) as { - url: string; - fileName: string; - }[]; - for (let i = 0; i < validUrls.length; i++) { - window.open(validUrls[i].url, "_blank"); - if (i < validUrls.length - 1) { - await new Promise((r) => setTimeout(r, 300)); + // Open each file in a new tab with a small delay to avoid popup blockers + const validUrls = urlResults.filter(Boolean) as { + url: string; + fileName: string; + }[]; + for (let i = 0; i < validUrls.length; i++) { + window.open(validUrls[i].url, "_blank"); + if (i < validUrls.length - 1) { + await new Promise((r) => setTimeout(r, 300)); + } } - } - if (validUrls.length < filesToDownload.length) { - window.alert( - `Opened ${validUrls.length} of ${filesToDownload.length} files. Some files could not be loaded.`, - ); + if (validUrls.length < filesToDownload.length) { + window.alert( + `Opened ${validUrls.length} of ${filesToDownload.length} files. Some files could not be loaded.`, + ); + } + } finally { + downloadingRef.current = false; } - } finally { - downloadingRef.current = false; - } - }, [getFileKey]); + }, + [getFileKey], + ); return (