diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx
index b34ab47..3637ff5 100644
--- a/src/app/(app)/orders/[id]/page.tsx
+++ b/src/app/(app)/orders/[id]/page.tsx
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/table";
import { DeleteDialog } from "@/components/delete-dialog";
import { CancelOrderDialog } from "@/components/cancel-order-dialog";
+import { OrdersReviewModalMount } from "@/components/orders-review-modal-mount";
import {
ArrowLeft,
Trash2,
@@ -25,6 +26,7 @@ import {
Mail,
FileText,
Ban,
+ AlertTriangle,
FileDown,
ClipboardEdit,
Inbox,
@@ -107,6 +109,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
const [loading, setLoading] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
+ /** Review modal — gated by `reviewOpen` so the mount component
+ * only runs its fetch sequence when the operator actually asks
+ * for it. Same affordance as the orders-list page so the two
+ * surfaces behave identically. */
+ const [reviewOpen, setReviewOpen] = useState(false);
// ─── Quote-matching local state ──────────────────────────────
// The match attempt is keyed off the saved `quote_match_method` /
@@ -420,6 +427,22 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
+ {/* Review — opens the shared review modal (same
+ affordance as the per-row button on /orders). Warn-amber
+ styling mirrors the list-page button so the eye reads
+ "this is the same workflow" between the two surfaces. */}
+
setReviewOpen(true)}
+ className="text-warn border-warn/40 hover:bg-warn/10 hover:text-warn"
+ data-testid="review-order-button"
+ aria-label={`Review order ${order.order_number}`}
+ title={`Review ${order.order_number} against email & attachments`}
+ >
+
+ Review
+
router.push(`/orders/${id}/process`)}>
Process Order
@@ -915,6 +938,15 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
onConfirm={handleCancel}
/>
+ {reviewOpen && (
+ setReviewOpen(false)}
+ onSaved={() => setReviewOpen(false)}
+ />
+ )}
+
{/* ─── Regenerate PO modal ────────────────────────────────── */}
{/* Captures a reason before generating the next PDF version.
The reason is REQUIRED for v≥2 so the audit trail explains
diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx
index 380735c..38d83f4 100644
--- a/src/app/(app)/orders/page.tsx
+++ b/src/app/(app)/orders/page.tsx
@@ -40,7 +40,7 @@ import {
type TabGroup,
} from "@/lib/item-status-ui";
import { ItemStatusTabs } from "@/components/item-status-tabs";
-import { OrdersReviewModal } from "@/components/orders-review-modal";
+import { OrdersReviewModalMount } from "@/components/orders-review-modal-mount";
import { DuplicateOrdersDialog } from "@/components/duplicate-orders-dialog";
import type { OrderItem, ItemStatus } from "@/lib/types";
@@ -109,196 +109,6 @@ async function fetchDashboardJson(url: string): Promise {
return body;
}
-/**
- * Convert the order's `order_attachements` file field into the
- * `QuoteAttachment[]` shape the review modal expects. The PB file field
- * stores bare filenames (no collection-id token), so we synthesise the
- * `kind` / `content_type` pairs from the filename extension. `size_bytes`
- * is not available from a PB file field, so we leave it at 0.
- *
- * Kept inline because it's tightly coupled to the modal's
- * `QuoteAttachment` interface — extracting it would just push the same
- * code into another file.
- */
-function orderAttachementsToQuotes(
- order: import("@/lib/types").Order,
-): import("@/lib/types").QuoteAttachment[] {
- const files = order.order_attachements ?? [];
- return files.map((filename) => {
- const lower = filename.toLowerCase();
- let kind: "pdf" | "image" | "doc" = "doc";
- let content_type = "application/octet-stream";
- if (lower.endsWith(".pdf")) {
- kind = "pdf";
- content_type = "application/pdf";
- } else if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lower)) {
- const ext = lower.split(".").pop() ?? "";
- const imageMime =
- ext === "png"
- ? "png"
- : ext === "gif"
- ? "gif"
- : ext === "webp"
- ? "webp"
- : ext === "svg"
- ? "svg+xml"
- : ext === "bmp"
- ? "bmp"
- : "jpeg";
- kind = "image";
- content_type = `image/${imageMime}`;
- } else if (lower.endsWith(".docx")) {
- content_type =
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
- } else if (lower.endsWith(".txt")) {
- content_type = "text/plain";
- }
- return {
- id: order.id,
- created: order.created,
- updated: order.updated,
- collectionId: order.collectionId,
- collectionName: "orders",
- quote: order.quote,
- filename,
- file: [filename],
- content_type,
- size_bytes: 0,
- email_message_id: "",
- kind,
- };
- });
-}
-
-/* ============================================================================
- * 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: { sender: string; subject: string; body: string };
- attachments: import("@/lib/types").QuoteAttachment[];
- } | null>(null);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
- (async () => {
- try {
- const review = await fetchDashboardJson<{
- order: import("@/lib/types").Order;
- items: OrderItem[];
- customers: import("@/lib/types").Customer[];
- quoteEmail: { sender: string; subject: string; body: string };
- }>(`/api/v1/orders/${encodeURIComponent(orderId)}/review`);
- if (cancelled) return;
-
- const { order, items, customers, quoteEmail } = review;
-
- // Source attachments from `order_attachements` — the multi-file
- // field ON the order record itself (orders collection
- // `pbc_3527180448`, `maxSelect: 10`, accepts any MIME). Older
- // orders may still have attachments stored under
- // `quote_attachments` on the parent quote, but those records
- // were migrated to the order-level field on 2026-07-29, so
- // reading from the order is authoritative going forward.
- // The PB file field stores bare filenames (no collection-id
- // token); we synthesise the QuoteAttachment shape the modal
- // expects (kind + content_type) from the filename extension.
- const attachments = orderAttachementsToQuotes(order);
-
- 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) ||
- customers.find((c) => c.id === order.customer) ||
- customers[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,
- quoteEmail,
- attachments,
- });
- } catch (err) {
- if (!cancelled) {
- setError(err instanceof Error ? err.message : "Failed to load order");
- }
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [orderId]);
-
- if (error) {
- return (
-
- );
- }
-
- if (!data) {
- return (
-
-
-
- );
- }
-
- return (
-
- );
-}
export default function OrdersPage() {
const router = useRouter();
diff --git a/src/components/orders-review-modal-mount.test.tsx b/src/components/orders-review-modal-mount.test.tsx
new file mode 100644
index 0000000..8c5b31b
--- /dev/null
+++ b/src/components/orders-review-modal-mount.test.tsx
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { OrdersReviewModalMount } from "./orders-review-modal-mount";
+
+// The pocketbase auth token accessor is the only side effect the
+// mount reaches for on the client. Stub it so the fetch picks up
+// the same Authorization header as production.
+vi.mock("@/lib/pocketbase", () => ({
+ default: () => ({ authStore: { token: "test-token" } }),
+}));
+
+const sampleReviewResponse = {
+ order: {
+ id: "order-1",
+ created: "2026-01-01T00:00:00.000Z",
+ updated: "2026-01-02T00:00:00.000Z",
+ collectionId: "abc",
+ collectionName: "orders",
+ order_number: "ORD-001",
+ customer: "cust-1",
+ company: "comp-1",
+ quote: "quote-1",
+ order_attachements: ["drawing.pdf", "photo.png"],
+ notes: "",
+ rep: "",
+ invoiced: false,
+ email_message_id: "",
+ expand: {
+ customer: {
+ id: "cust-1",
+ created: "",
+ updated: "",
+ collectionId: "abc",
+ collectionName: "customers",
+ customer: "J. Smith",
+ company: "comp-1",
+ },
+ company: {
+ id: "comp-1",
+ created: "",
+ updated: "",
+ collectionId: "abc",
+ collectionName: "companies",
+ company: "Acme",
+ },
+ },
+ },
+ items: [
+ {
+ id: "item-1",
+ created: "",
+ updated: "",
+ collectionId: "",
+ collectionName: "order_items",
+ order: "order-1",
+ description: "HSS drill",
+ po_description: "",
+ quantity: 2,
+ order_qty: 2,
+ selling_price: 50,
+ supplier: "sup-1",
+ cost_price: 25,
+ markup: 100,
+ purchase_order: "",
+ in_stock: false,
+ notes: "",
+ item_status: "pending",
+ },
+ ],
+ customers: [
+ {
+ id: "cust-1",
+ created: "",
+ updated: "",
+ collectionId: "",
+ collectionName: "customers",
+ customer: "J. Smith",
+ company: "comp-1",
+ },
+ ],
+ quoteEmail: {
+ sender: "buyer@example.com",
+ subject: "RFQ ORD-001",
+ body: "Please quote
",
+ },
+};
+
+describe("OrdersReviewModalMount", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders the spinner while fetching", () => {
+ vi.spyOn(global, "fetch").mockImplementation(
+ () => new Promise(() => {}) // never resolves
+ );
+ render(
+ {}}
+ onSaved={() => {}}
+ />,
+ );
+ // The Loader2 component renders an SVG with the `animate-spin`
+ // class while the fetch is in flight.
+ expect(document.querySelector(".animate-spin")).toBeTruthy();
+ });
+
+ it("fetches /api/v1/orders/{id}/review with the bearer token", async () => {
+ const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(sampleReviewResponse), { status: 200 }),
+ );
+ render(
+ {}}
+ onSaved={() => {}}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "/api/v1/orders/order-1/review",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "test-token",
+ }),
+ }),
+ );
+ });
+ });
+
+ it("renders the OrdersReviewModal once the fetch resolves", async () => {
+ vi.spyOn(global, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(sampleReviewResponse), { status: 200 }),
+ );
+ render(
+ {}}
+ onSaved={() => {}}
+ />,
+ );
+ // The modal renders the order number into a read-only input.
+ // `getByDisplayValue` is the right matcher because the order
+ // number never appears as plain text in the DOM.
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("ORD-001")).toBeInTheDocument();
+ });
+ });
+
+ it("renders an error card when the API fails", async () => {
+ vi.spyOn(global, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ error: "PB down" }), { status: 500 }),
+ );
+ render(
+ {}}
+ onSaved={() => {}}
+ />,
+ );
+ await waitFor(() => {
+ expect(screen.getByText("PB down")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/orders-review-modal-mount.tsx b/src/components/orders-review-modal-mount.tsx
new file mode 100644
index 0000000..20b1917
--- /dev/null
+++ b/src/components/orders-review-modal-mount.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { OrdersReviewModal } from "@/components/orders-review-modal";
+import type {
+ Company,
+ Customer,
+ Order,
+ OrderItem,
+ QuoteAttachment,
+} from "@/lib/types";
+
+/**
+ * Convert the order's `order_attachements` file field into the
+ * `QuoteAttachment[]` shape the review modal expects. The PB file field
+ * stores bare filenames (no collection-id token), so we synthesise the
+ * `kind` / `content_type` pairs from the filename extension. `size_bytes`
+ * is not available from a PB file field, so we leave it at 0.
+ */
+function orderAttachementsToQuotes(order: Order): QuoteAttachment[] {
+ const files = order.order_attachements ?? [];
+ return files.map((filename) => {
+ const lower = filename.toLowerCase();
+ let kind: "pdf" | "image" | "doc" = "doc";
+ let content_type = "application/octet-stream";
+ if (lower.endsWith(".pdf")) {
+ kind = "pdf";
+ content_type = "application/pdf";
+ } else if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lower)) {
+ const ext = lower.split(".").pop() ?? "";
+ const imageMime =
+ ext === "png"
+ ? "png"
+ : ext === "gif"
+ ? "gif"
+ : ext === "webp"
+ ? "webp"
+ : ext === "svg"
+ ? "svg+xml"
+ : ext === "bmp"
+ ? "bmp"
+ : "jpeg";
+ kind = "image";
+ content_type = `image/${imageMime}`;
+ } else if (lower.endsWith(".docx")) {
+ content_type =
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+ } else if (lower.endsWith(".txt")) {
+ content_type = "text/plain";
+ }
+ return {
+ id: order.id,
+ created: order.created,
+ updated: order.updated,
+ collectionId: order.collectionId,
+ collectionName: "orders",
+ quote: order.quote,
+ filename,
+ file: [filename],
+ content_type,
+ size_bytes: 0,
+ email_message_id: "",
+ kind,
+ };
+ });
+}
+
+export interface OrdersReviewModalMountProps {
+ orderId: string;
+ onClose: () => void;
+ onSaved: () => void;
+}
+
+interface ReviewPayload {
+ order: Order;
+ items: OrderItem[];
+ customers: Customer[];
+ quoteEmail: { sender: string; subject: string; body: string };
+}
+
+interface ReviewData extends ReviewPayload {
+ // Resolved from the order's expand or the customer list (see the
+ // fallback chain in the fetch effect). Typed as non-null because
+ // we always produce one — the modal renders "(unknown)" for a
+ // missing `company` and a placeholder for a missing `customer`.
+ customer: Customer;
+ company: Company | null;
+ attachments: QuoteAttachment[];
+}
+
+/**
+ * Fetches the order's full details, line items, the source quote's
+ * email + attachments, and the customer/company lists, then renders
+ * `OrdersReviewModal`. Shared between `/orders` (list page) and
+ * `/orders/[id]` (detail page) so the same review workflow is
+ * available from either entry point.
+ *
+ * Lives outside `orders-review-modal.tsx` because the modal itself is
+ * a pure renderer — keeping the fetch sequence in a sibling component
+ * means the modal can be reused (e.g. in tests, or future embedded
+ * contexts) without dragging the data layer with it.
+ */
+export function OrdersReviewModalMount({
+ orderId,
+ onClose,
+ onSaved,
+}: OrdersReviewModalMountProps) {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const pbToken =
+ typeof window !== "undefined"
+ ? // Lazy import via dynamic require to avoid pulling the
+ // pocketbase singleton into a server bundle — the
+ // modal-mount only ever runs client-side anyway, but
+ // the dependency graph keeps it cleaner this way.
+ (await import("@/lib/pocketbase")).default().authStore.token
+ : null;
+ const headers: HeadersInit = pbToken
+ ? { Authorization: pbToken }
+ : {};
+ const res = await fetch(
+ `/api/v1/orders/${encodeURIComponent(orderId)}/review`,
+ { headers, cache: "no-store" },
+ );
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(
+ typeof body?.error === "string"
+ ? body.error
+ : `Request failed (${res.status})`,
+ );
+ }
+ const review = (await res.json()) as ReviewPayload;
+ if (cancelled) return;
+
+ const { order, items, customers, quoteEmail } = review;
+
+ // Source attachments from `order_attachements` — the multi-file
+ // field ON the order record itself (orders collection
+ // `pbc_3527180448`, `maxSelect: 10`, accepts any MIME). Older
+ // orders may still have attachments stored under
+ // `quote_attachments` on the parent quote, but those records
+ // were migrated to the order-level field on 2026-07-29, so
+ // reading from the order is authoritative going forward.
+ const attachments = orderAttachementsToQuotes(order);
+
+ 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 Customer | undefined) ||
+ customers.find((c) => c.id === order.customer) ||
+ customers[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 Company | undefined) || null;
+
+ setData({
+ order,
+ items,
+ customer,
+ company,
+ customers,
+ quoteEmail,
+ attachments,
+ });
+ } catch (err) {
+ if (!cancelled) {
+ setError(
+ err instanceof Error ? err.message : "Failed to load order",
+ );
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [orderId]);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}