From 0e6c3d8f0ccba8fa80680a07ba220aa5cb7c20ee Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 14:57:39 +0200 Subject: [PATCH] feat(orders): review modal scaffold (header + items + dirty state) Co-Authored-By: Claude Opus 4.8 Co-authored-by: Orca --- src/components/orders-review-modal.test.tsx | 191 +++++++ src/components/orders-review-modal.tsx | 550 ++++++++++++++++++++ 2 files changed, 741 insertions(+) create mode 100644 src/components/orders-review-modal.test.tsx create mode 100644 src/components/orders-review-modal.tsx diff --git a/src/components/orders-review-modal.test.tsx b/src/components/orders-review-modal.test.tsx new file mode 100644 index 0000000..4f5c545 --- /dev/null +++ b/src/components/orders-review-modal.test.tsx @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +vi.mock("@/components/ui/customer-search", () => ({ + CustomerSearch: ({ + onValueChange, + value, + }: { + onValueChange: (v: string) => void; + value: string; + customers: unknown[]; + }) => ( + + ), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { OrdersReviewModal } from "./orders-review-modal"; +import type { Order, OrderItem, Customer, Company } from "@/lib/types"; + +const baseOrder: Order = { + id: "order-1", + created: "2026-07-28T00:00:00Z", + updated: "2026-07-28T00:00:00Z", + collectionId: "c1", + collectionName: "orders", + order_number: "ORD-2026-07-001", + company: "comp-1", + customer: "cust-1", + quote: "quote-1", + notes: "", + quote_matching_attempted: true, + quote_match_confidence: 1, + potential_quote: "", + potential_quote_confidence: 0, + is_repair_order: false, + rep: "Maria", + invoiced: false, + quote_reference_text: "", + quote_match_method: "", +}; + +const baseCustomer: Customer = { + id: "cust-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + customer: "J. Smith", + email: "", + alternative_email: "", + buyer_email: "", + number: "", + company: "comp-1", + dateadded: "", + lastvisiteddate: "", + rep: "", + description: "", +}; + +const baseCompany: Company = { + id: "comp-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + company: "Acme (Pty) Ltd", + address: "", + vendor: "", + company_email: "", + company_tel: "", +}; + +const baseItems: OrderItem[] = [ + { + id: "item-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + description: "HSS drill 6mm", + po_description: "", + quantity: 2, + order_qty: 2, + selling_price: 50, + supplier: "", + cost_price: 25, + markup: 100, + purchase_order: "", + item_status: "pending", + in_stock: false, + notes: "", + }, +]; + +const baseProps = () => ({ + open: true, + onClose: vi.fn(), + onSaved: vi.fn(), + order: baseOrder, + items: baseItems, + customers: [baseCustomer], + customer: baseCustomer, + company: baseCompany, + quoteEmail: { subject: "Re: RFQ", body: "Hi team..." }, + attachments: [], +}); + +describe("OrdersReviewModal", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders order number and customer in the header", () => { + render(); + expect( + screen.getByDisplayValue("ORD-2026-07-001"), + ).toBeInTheDocument(); + expect(screen.getByText("J. Smith")).toBeInTheDocument(); + expect(screen.getByText("Acme (Pty) Ltd")).toBeInTheDocument(); + }); + + it("renders one row per line item", () => { + render(); + expect(screen.getByText("HSS drill 6mm")).toBeInTheDocument(); + expect(screen.getByDisplayValue("2")).toBeInTheDocument(); + expect(screen.getByDisplayValue("50")).toBeInTheDocument(); + }); + + it("Save button is disabled until a field is changed", () => { + render(); + const save = screen.getByRole("button", { name: /save/i }); + expect(save).toBeDisabled(); + }); + + it("Save button becomes enabled after editing the order number", () => { + render(); + const numberInput = screen.getByDisplayValue("ORD-2026-07-001"); + fireEvent.change(numberInput, { + target: { value: "ORD-2026-07-014" }, + }); + const save = screen.getByRole("button", { name: /save/i }); + expect(save).not.toBeDisabled(); + }); + + it("renders inline error from the API on 409", async () => { + const fetchMock = vi + .spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "order_number_collision", + collidingOrderId: "order-other", + attemptedNumber: "ORD-2026-07-014", + }), + { status: 409 }, + ), + ); + + render(); + const numberInput = screen.getByDisplayValue("ORD-2026-07-001"); + fireEvent.change(numberInput, { + target: { value: "ORD-2026-07-014" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect( + screen.getByText(/ORD-2026-07-014 is already used by another order/i), + ).toBeInTheDocument(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/orders/order-1/review-save", + expect.objectContaining({ method: "POST" }), + ); + }); +}); diff --git a/src/components/orders-review-modal.tsx b/src/components/orders-review-modal.tsx new file mode 100644 index 0000000..417b118 --- /dev/null +++ b/src/components/orders-review-modal.tsx @@ -0,0 +1,550 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Check, + ExternalLink, + Loader2, + Mail, + Paperclip, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CustomerSearch } from "@/components/ui/customer-search"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import type { + Company, + Customer, + Order, + OrderItem, + QuoteAttachment, +} from "@/lib/types"; + +/** + * Orders review modal — Task 4 scaffold. + * + * Renders an order's header (order_number, customer, company, rep) and + * editable line items on the left, with the source-of-truth panel on + * the right. The preview chain (email body + attachments) lands in + * Task 5; this version ships the header + items + dirty-state gate + + * save flow (with inline 409 collision error rendering). + * + * The save flow posts a single patch to `/api/v1/orders/{id}/review-save` + * — see the route for the request shape and error envelope. + */ + +const round2 = (n: number) => Math.round(n * 100) / 100; + +type Lock = "cost" | "selling"; + +export interface OrdersReviewModalProps { + open: boolean; + onClose: () => void; + onSaved: () => void; + order: Order; + items: OrderItem[]; + customers: Customer[]; + customer: Customer; + company: Company; + quoteEmail: { subject: string; body: string }; + attachments: QuoteAttachment[]; +} + +interface ItemDraft { + quantity: number | null; + selling_price: number | null; + cost_price: number | null; + markup: number | null; + lock: Lock; +} + +function initialItemDraft(it: OrderItem): ItemDraft { + return { + quantity: it.quantity, + selling_price: it.selling_price, + cost_price: it.cost_price, + markup: it.markup ?? null, + lock: + it.cost_price && it.cost_price > 0 ? "cost" : "selling", + }; +} + +export function OrdersReviewModal({ + open, + onClose, + onSaved, + order, + items, + customers, + customer, + company, + quoteEmail, + attachments, +}: OrdersReviewModalProps) { + const [orderNumber, setOrderNumber] = useState(order.order_number); + const [customerId, setCustomerId] = useState(order.customer); + const [itemDrafts, setItemDrafts] = useState>( + () => + Object.fromEntries( + items.map((it) => [it.id, initialItemDraft(it)]), + ), + ); + const [numberError, setNumberError] = useState(null); + const [collidingOrderId, setCollidingOrderId] = useState(null); + const [saving, setSaving] = useState(false); + + // Re-seed local state when the modal opens for a different order. + // Parent already remounts via `key={order.id}`, but defensively + // re-seed here too in case anyone forgets the key. + useEffect(() => { + if (!open) return; + setOrderNumber(order.order_number); + setCustomerId(order.customer); + setItemDrafts( + Object.fromEntries(items.map((it) => [it.id, initialItemDraft(it)])), + ); + setNumberError(null); + setCollidingOrderId(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, order.id]); + + const dirty = useMemo(() => { + if (orderNumber !== order.order_number) return true; + if (customerId !== order.customer) return true; + for (const it of items) { + const d = itemDrafts[it.id]; + if (!d) continue; + if (d.quantity !== it.quantity) return true; + if (d.selling_price !== it.selling_price) return true; + if (d.cost_price !== it.cost_price) return true; + if ((d.markup ?? null) !== (it.markup ?? null)) return true; + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderNumber, customerId, itemDrafts, order.id]); + + const updateItemDraft = useCallback( + (id: string, patch: Partial) => { + setItemDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); + }, + [], + ); + + const onCostChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + const newSelling = round2(value * (1 + (d.markup ?? 0) / 100)); + updateItemDraft(id, { + cost_price: value, + selling_price: newSelling, + lock: "cost", + }); + }; + + const onSellingChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + const newCost = + (d.markup ?? 0) > -100 ? round2(value / (1 + (d.markup ?? 0) / 100)) : 0; + updateItemDraft(id, { + selling_price: value, + cost_price: newCost, + lock: "selling", + }); + }; + + const onMarkupChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + if (d.lock === "cost") { + const newSelling = round2((d.cost_price ?? 0) * (1 + value / 100)); + updateItemDraft(id, { markup: value, selling_price: newSelling }); + } else { + const newCost = + value > -100 ? round2((d.selling_price ?? 0) / (1 + value / 100)) : 0; + updateItemDraft(id, { markup: value, cost_price: newCost }); + } + }; + + const onQuantityChange = (id: string, raw: string) => { + const value = raw === "" ? null : Number(raw); + updateItemDraft(id, { quantity: value }); + }; + + const handleSave = async () => { + setSaving(true); + setNumberError(null); + setCollidingOrderId(null); + + const body: { + order_number?: string; + customer?: string; + items?: Array<{ + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + }>; + } = {}; + + if (orderNumber !== order.order_number) body.order_number = orderNumber; + if (customerId !== order.customer) body.customer = customerId; + + const dirtyItems = items + .map((it) => { + const d = itemDrafts[it.id]; + if (!d) return null; + const changed: { + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + } = { id: it.id }; + let isDirty = false; + if (d.quantity !== it.quantity) { + changed.quantity = d.quantity; + isDirty = true; + } + if (d.selling_price !== it.selling_price) { + changed.selling_price = d.selling_price; + isDirty = true; + } + if (d.cost_price !== it.cost_price) { + changed.cost_price = d.cost_price; + isDirty = true; + } + return isDirty ? changed : null; + }) + .filter((x): x is NonNullable => x !== null); + + if (dirtyItems.length > 0) body.items = dirtyItems; + + try { + const res = await fetch( + `/api/v1/orders/${order.id}/review-save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (res.status === 409) { + const err = await res.json(); + setNumberError( + `${err.attemptedNumber} is already used by another order.`, + ); + setCollidingOrderId(err.collidingOrderId); + return; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Save failed (HTTP ${res.status})`); + } + + const result = await res.json(); + if (result.items?.failed?.length > 0) { + toast.error( + `Saved ${result.items.updated} items, ${result.items.failed.length} failed`, + ); + return; + } + + toast.success("Saved"); + onSaved(); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + // Esc closes without saving (no confirm-discard in v1; dirty work is + // small and the operator can re-open to retry). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !saving) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, saving, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget && !saving) onClose(); + }} + > + + +
+ + + Review order + +

+ Verify AI-populated fields against the source email & + attachments. +

+
+ +
+ + + {/* Header strip */} +
+
+ + { + setOrderNumber(e.target.value); + setNumberError(null); + setCollidingOrderId(null); + }} + className={cn( + "h-9 font-mono text-sm", + numberError && "border-destructive focus-visible:ring-destructive/40", + )} + data-testid="review-order-number" + /> + {numberError && ( +

+ + {numberError} + {collidingOrderId && ( + + open existing + + )} +

+ )} +
+ +
+ + +

+ {customer.customer || "(no customer name)"} +

+
+ +
+ +

+ {company.company} +

+
+ +
+ +

+ {order.rep || "—"} +

+
+
+ + {/* Items table (left side) + preview placeholder (right side). + The preview region is filled in by Task 5. */} +
+
+

+ Order items +

+ {items.length === 0 ? ( +

+ No line items on this order. +

+ ) : ( +
    + {items.map((it) => { + const d = itemDrafts[it.id]; + if (!d) return null; + return ( +
  • +

    + {it.description || "(no description)"} +

    +
    +
    + + + onQuantityChange(it.id, e.target.value) + } + className="h-8 text-xs tabular-nums" + /> +
    +
    + + onCostChange(it.id, e.target.value)} + className={cn( + "h-8 text-xs tabular-nums", + d.lock === "cost" && "border-warn/40 bg-warn/10", + )} + /> +
    +
    + + + onMarkupChange(it.id, e.target.value) + } + className="h-8 text-xs tabular-nums" + /> +
    +
    + + + onSellingChange(it.id, e.target.value) + } + className={cn( + "h-8 text-xs tabular-nums", + d.lock === "selling" && "border-warn/40 bg-warn/10", + )} + /> +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Source of truth +

+ {/* Task 5 fills this in. */} + +
+
+
+ +
+ + +
+
+
+ ); +} + +// --- Source-of-truth panel (filled out in Task 5) ------------------------- +function SourceOfTruth({ + quoteEmail, + attachments, +}: { + quoteEmail: { subject: string; body: string }; + attachments: QuoteAttachment[]; +}) { + if (attachments.length === 0) { + return ( +
+
+ + Email +
+

{quoteEmail.subject || "(no subject)"}

+
+          {quoteEmail.body || "(no body)"}
+        
+
+ ); + } + return ( +
+
+ + Attachments ({attachments.length}) — preview added in Task 5 +
+
    + {attachments.map((a) => ( +
  • + {a.filename} +
  • + ))} +
+
+ ); +}