+ Verify AI-populated fields against the source email & + attachments. +
+
+
+ {company.company} +
++ {order.rep || "—"} +
++ No line items on this order. +
+ ) : ( ++ {it.description || "(no description)"} +
+{quoteEmail.subject || "(no subject)"}
+
+ {quoteEmail.body || "(no body)"}
+
+ + No inline preview for {firstOther.kind ?? "this"} file type. +
++ + {attachments.filter( + (a) => a.kind !== "pdf" && a.kind !== "image", + ).length}{" "} + unpreviewable attachment(s) — open to view. +
++ {quoteEmail.subject || "(no subject)"} +
+
+ {quoteEmail.body || "(no body)"}
+
+ {error}
+ +` block. + +- [ ] **Step 7: Commit any smoke fixes** + +If steps 2–6 surfaced any small UI tweak (alignment, colour, spacing), commit as a follow-up: + +```bash +git add src/app/\(app\)/orders/page.tsx src/components/orders-review-modal.tsx +git commit -m "fix(orders): review modal smoke fixes from manual QA + +Co-Authored-By: Claude Opus 4.8" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Warn-amber icon button on every card → Task 6 (Step 3). +- Side-by-side modal with editable items + source preview → Tasks 4, 5. +- `updateOrderReviewFields` + collision error → Task 1. +- `updateOrderItemsPrices` with per-row failure → Task 2. +- `POST /api/v1/orders/[id]/review-save` → Task 3. +- Inline attachment preview chain (PDF → image → "no preview" → email) → Task 5. +- CustomerSearch for customer → Task 4 (component), Task 6 (data fetch). +- Save inline + card refresh via realtime → Task 6 (`fetchOrders()` after save). +- Test coverage → Tasks 1, 2, 3, 4 each ship a test file. + +**Placeholders:** none. Every code step has the actual code. + +**Type consistency:** `OrderNumberCollisionError` is exported and imported consistently across Tasks 1, 3, and the route test. `updateOrderReviewFields({ order_number?, customer? })` shape matches between Tasks 1, 3, and 4. `updateOrderItemsPrices` signature matches between Tasks 2 and 3. The modal props match between Task 4 and the mount component in Task 6. + +**Open ambiguity:** none at write-time. The `quote` expand on `getOrder` may not include `email_subject` / `email_body` by default in `pb-helpers.getOrder` — Task 6 includes an explicit `expand: "company,customer,quote"` argument and casts the quote shape inline, which keeps the contract local to the mount helper. If the running PB instance rejects the expand string (it shouldn't — `quote` is a valid relation), the email fields just render empty and the email fallback handles it. diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index 083735a..c93cd8a 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,150 @@ 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 | null; + 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"); + // Use the BROWSER PB instance (not the admin one) — admin PB + // requires server-only env vars and would break in the browser + // bundle. The user's session PB already has read access to + // customers + quote_attachments, so we don't need privileges. + const { getPocketBase } = await import("@/lib/pocketbase"); + const pb = getPocketBase(); + const [order, items, customersResult] = await Promise.all([ + getOrder(orderId), + getOrderItems(orderId), + pb.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 pb + .collection("quote_attachments") + .getFullList({ + filter: `quote = "${quote.id}"`, + }) + : []; + + if (cancelled) return; + + // Customer fallback chain: expand → match by ID in the loaded list → + // first customer as a last resort. The "first customer" fallback + // is misleading (it's just the newest customer in PB, not anything + // related to the order), but it preserves the previous UX if the + // list happens to be empty after filtering. + const customer = + (order.expand?.customer as import("@/lib/types").Customer) || + customersResult.find((c) => c.id === order.customer) || + customersResult[0]; + // Company can be null if the order's company record isn't loaded. + // The modal renders "(unknown)" in that case rather than fabricating + // a stub with the wrong ID. + const company = + (order.expand?.company as import("@/lib/types").Company) || null; + + 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 ( + ++ ); + } + + if (!data) { + return ( +++{error}
+ +++ ); + } + + return ( ++ + ); +} + export default function OrdersPage() { const router = useRouter(); const [orders, setOrders] = useState ([]); @@ -87,6 +233,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 +359,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. @@ -319,6 +470,7 @@ export default function OrdersPage() { onToggleExpand={() => toggleExpand(order.id)} onToggleShowAll={() => toggleShowAll(order.id)} onOpen={() => router.push(`/orders/${order.id}`)} + onOpenReview={() => openReview(order.id)} /> ))}