feat(orders): review modal scaffold (header + items + dirty state)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 14:57:39 +02:00
parent cd33e425fc
commit 0e6c3d8f0c
2 changed files with 741 additions and 0 deletions

View file

@ -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[];
}) => (
<button
type="button"
data-testid="customer-search-mock"
onClick={() => onValueChange("cust-new")}
>
{value || "(unset)"}
</button>
),
}));
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(<OrdersReviewModal {...baseProps()} />);
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(<OrdersReviewModal {...baseProps()} />);
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(<OrdersReviewModal {...baseProps()} />);
const save = screen.getByRole("button", { name: /save/i });
expect(save).toBeDisabled();
});
it("Save button becomes enabled after editing the order number", () => {
render(<OrdersReviewModal {...baseProps()} />);
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(<OrdersReviewModal {...baseProps()} />);
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" }),
);
});
});

View file

@ -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<Record<string, ItemDraft>>(
() =>
Object.fromEntries(
items.map((it) => [it.id, initialItemDraft(it)]),
),
);
const [numberError, setNumberError] = useState<string | null>(null);
const [collidingOrderId, setCollidingOrderId] = useState<string | null>(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<ItemDraft>) => {
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<typeof x> => 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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="orders-review-title"
onClick={(e) => {
if (e.target === e.currentTarget && !saving) onClose();
}}
>
<Card className="w-full max-w-5xl max-h-[90vh] flex flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b">
<div className="min-w-0 flex-1">
<CardTitle
id="orders-review-title"
className="text-sm flex items-center gap-2"
>
<AlertTriangle className="h-4 w-4 text-warn-foreground" />
Review order
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Verify AI-populated fields against the source email &amp;
attachments.
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={saving}
aria-label="Close"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
{/* Header strip */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Order number</Label>
<Input
value={orderNumber}
onChange={(e) => {
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 && (
<p
className="text-[11px] text-destructive flex items-center gap-1"
data-testid="review-order-number-error"
>
<AlertTriangle className="h-3 w-3" />
{numberError}
{collidingOrderId && (
<a
href={`/orders/${collidingOrderId}`}
className="underline hover:no-underline inline-flex items-center gap-0.5"
>
open existing <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<CustomerSearch
value={customerId}
onValueChange={setCustomerId}
customers={customers}
placeholder="Pick a customer…"
/>
<p className="text-[11px] text-muted-foreground">
{customer.customer || "(no customer name)"}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Company</Label>
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
{company.company}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rep</Label>
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
{order.rep || "—"}
</p>
</div>
</div>
{/* Items table (left side) + preview placeholder (right side).
The preview region is filled in by Task 5. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Order items
</h3>
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No line items on this order.
</p>
) : (
<ul className="divide-y rounded-md border bg-card">
{items.map((it) => {
const d = itemDrafts[it.id];
if (!d) return null;
return (
<li key={it.id} className="p-3 space-y-2">
<p className="text-sm font-medium truncate">
{it.description || "(no description)"}
</p>
<div className="grid grid-cols-4 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Qty
</Label>
<Input
type="number"
value={d.quantity ?? ""}
onChange={(e) =>
onQuantityChange(it.id, e.target.value)
}
className="h-8 text-xs tabular-nums"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Cost
</Label>
<Input
type="number"
value={d.cost_price ?? ""}
onChange={(e) => onCostChange(it.id, e.target.value)}
className={cn(
"h-8 text-xs tabular-nums",
d.lock === "cost" && "border-warn/40 bg-warn/10",
)}
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Markup %
</Label>
<Input
type="number"
value={d.markup ?? ""}
onChange={(e) =>
onMarkupChange(it.id, e.target.value)
}
className="h-8 text-xs tabular-nums"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Selling
</Label>
<Input
type="number"
value={d.selling_price ?? ""}
onChange={(e) =>
onSellingChange(it.id, e.target.value)
}
className={cn(
"h-8 text-xs tabular-nums",
d.lock === "selling" && "border-warn/40 bg-warn/10",
)}
/>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Source of truth
</h3>
{/* Task 5 fills this in. */}
<SourceOfTruth
quoteEmail={quoteEmail}
attachments={attachments}
/>
</div>
</div>
</CardContent>
<div className="flex items-center justify-end gap-2 border-t p-4">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
data-testid="review-save-button"
>
{saving ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Check className="h-3 w-3 mr-1" />
)}
Save
</Button>
</div>
</Card>
</div>
);
}
// --- 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 (
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Mail className="h-3 w-3" />
Email
</div>
<p className="text-foreground font-medium">{quoteEmail.subject || "(no subject)"}</p>
<pre className="whitespace-pre-wrap font-sans text-muted-foreground max-h-64 overflow-y-auto">
{quoteEmail.body || "(no body)"}
</pre>
</div>
);
}
return (
<div className="rounded-md border bg-muted/30 p-3 text-xs space-y-2">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length}) — preview added in Task 5
</div>
<ul className="space-y-1">
{attachments.map((a) => (
<li key={a.id} className="text-muted-foreground truncate">
{a.filename}
</li>
))}
</ul>
</div>
);
}