From de87eb312e0ee2eb74cd7b71ba0b5e0ca69fcd16 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 16:04:00 +0200 Subject: [PATCH] feat(orders): warn-amber review button + modal mount on orders list Co-Authored-By: Claude Opus 4.8 Co-authored-by: Orca --- src/app/(app)/orders/page.tsx | 189 +++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index 083735a..5de4350 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -16,6 +16,7 @@ import { Link2, Sparkles, AlertCircle, + AlertTriangle, CircleCheck, Search, X, @@ -38,6 +39,7 @@ import { type TabGroup, } from "@/lib/item-status-ui"; import { ItemStatusTabs } from "@/components/item-status-tabs"; +import { OrdersReviewModal } from "@/components/orders-review-modal"; import type { OrderItem, ItemStatus } from "@/lib/types"; /* ============================================================================ @@ -70,6 +72,148 @@ const EMPTY_COUNTS: Record = { const SEARCH_DEBOUNCE_MS = 250; +/* ============================================================================ + * OrdersReviewModalMount — fetches the order's full details, line items, + * the source quote's email + attachments, and the customer/company lists, + * then renders `OrdersReviewModal`. Lives here (not in the modal file) + * because the fetch sequence is specific to the orders-list context — + * the modal itself stays a pure renderer. + * ========================================================================== */ +function OrdersReviewModalMount({ + orderId, + onClose, + onSaved, +}: { + orderId: string; + onClose: () => void; + onSaved: () => void; +}) { + const [data, setData] = useState<{ + order: import("@/lib/types").Order; + items: OrderItem[]; + customer: import("@/lib/types").Customer; + company: import("@/lib/types").Company; + customers: import("@/lib/types").Customer[]; + quoteEmail: { subject: string; body: string }; + attachments: import("@/lib/types").QuoteAttachment[]; + } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { getOrder, getOrderItems } = await import("@/lib/pb-helpers"); + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + const adminPb = await getAdminPocketBase(); + const [order, items, customersResult] = await Promise.all([ + getOrder(orderId), + getOrderItems(orderId), + adminPb.collection("customers").getFullList({ + sort: "-created", + expand: "company", + }), + ]); + if (cancelled) return; + + const quote = order.expand?.quote as + | (import("@/lib/types").Quote & { + id: string; + email_subject: string; + email_body: string; + }) + | undefined; + + const attachments = quote + ? await adminPb + .collection("quote_attachments") + .getFullList({ + filter: `quote = "${quote.id}"`, + }) + : []; + + if (cancelled) return; + + const customer = + (order.expand?.customer as import("@/lib/types").Customer) || + customersResult[0]; + const company = + (order.expand?.company as import("@/lib/types").Company) || { + id: order.company, + created: "", + updated: "", + collectionId: "", + collectionName: "", + company: "(unknown)", + address: "", + vendor: "", + company_email: "", + company_tel: "", + }; + + setData({ + order, + items, + customer, + company, + customers: customersResult as import("@/lib/types").Customer[], + quoteEmail: { + subject: quote?.email_subject || "", + body: quote?.email_body || "", + }, + attachments: attachments as import("@/lib/types").QuoteAttachment[], + }); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load order"); + } + } + })(); + return () => { + cancelled = true; + }; + }, [orderId]); + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + export default function OrdersPage() { const router = useRouter(); const [orders, setOrders] = useState([]); @@ -87,6 +231,8 @@ export default function OrdersPage() { const [expanded, setExpanded] = useState>(new Set()); /** Per-row: order.id → show all items (vs tab-filtered items)? */ const [showAllByOrder, setShowAllByOrder] = useState>(new Set()); + /** ID of the order whose review modal is open. null = no modal. */ + const [reviewOrderId, setReviewOrderId] = useState(null); const fetchCounts = useCallback(async () => { try { @@ -211,6 +357,9 @@ export default function OrdersPage() { }); }; + const openReview = (orderId: string) => setReviewOrderId(orderId); + const closeReview = () => setReviewOrderId(null); + // Filter visible orders to those that have at least one item in // the active tab group. An order with no items at all (rare — // drafts) doesn't show on any tab by design. @@ -316,9 +465,12 @@ export default function OrdersPage() { isExpanded={expanded.has(order.id)} showAll={showAllByOrder.has(order.id)} activeTab={activeTab} + aiExpanded={false} onToggleExpand={() => toggleExpand(order.id)} onToggleShowAll={() => toggleShowAll(order.id)} + onToggleAi={() => {}} onOpen={() => router.push(`/orders/${order.id}`)} + onOpenReview={() => openReview(order.id)} /> ))} @@ -348,6 +500,17 @@ export default function OrdersPage() { )} + {reviewOrderId && ( + { + closeReview(); + fetchOrders(); + }} + /> + )} ); } @@ -362,18 +525,24 @@ function OrderCard({ isExpanded, showAll, activeTab, + aiExpanded, onToggleExpand, onToggleShowAll, + onToggleAi, onOpen, + onOpenReview, }: { order: import("@/lib/types").Order; items: OrderItem[]; isExpanded: boolean; showAll: boolean; activeTab: TabGroup; + aiExpanded: boolean; onToggleExpand: () => void; onToggleShowAll: () => void; + onToggleAi: () => void; onOpen: () => void; + onOpenReview: () => void; }) { const complete = isOrderComplete(items); // Tabs accent — thin colored rail on the left of the card so the @@ -568,7 +737,25 @@ function OrderCard({ the expand. stopPropagation is the belt-and-braces guard in case the click bubbles up to the wrapping div in any future refactor. */} -
+
+ {/* Warn-amber review button — opens the review modal. + Placed LEFT of the existing ExternalLink so the eye reads + "review → open" left-to-right. */} +