From a0afa51600e5bdbf79b16d4a3a16d859a38db593 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 24 Aug 2026 07:26:18 +0200 Subject: [PATCH] refactor(orders): replace OrdersReviewModal with OrdersReviewModalMount and streamline attachment handling - Updated the orders page to use `OrdersReviewModalMount` for improved order review functionality. - Removed the inline `orderAttachementsToQuotes` function to simplify the code structure and enhance maintainability. - Introduced a review modal in the order detail page, allowing users to trigger the review process with a dedicated button. - Ensured consistent behavior between the orders list and detail pages regarding order review interactions. --- src/app/(app)/orders/[id]/page.tsx | 32 +++ src/app/(app)/orders/page.tsx | 192 +-------------- .../orders-review-modal-mount.test.tsx | 167 +++++++++++++ src/components/orders-review-modal-mount.tsx | 230 ++++++++++++++++++ 4 files changed, 430 insertions(+), 191 deletions(-) create mode 100644 src/components/orders-review-modal-mount.test.tsx create mode 100644 src/components/orders-review-modal-mount.tsx 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. */} + -
- - ); - } - - 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 ( +
+
+

{error}

+ +
+
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + return ( + + ); +}