Merge feature/orders-review-modal: review-order-modal feature

This commit is contained in:
SJCoetz 2026-07-29 16:29:49 +02:00
commit aa00dacb47
8 changed files with 4804 additions and 1 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<TabGroup, number> = {
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<string | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div className="bg-card rounded-md p-6 max-w-sm text-sm">
<p className="text-destructive">{error}</p>
<Button size="sm" onClick={onClose} className="mt-3">
Close
</Button>
</div>
</div>
);
}
if (!data) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<OrdersReviewModal
open
onClose={onClose}
onSaved={onSaved}
order={data.order}
items={data.items}
customers={data.customers}
customer={data.customer}
company={data.company}
quoteEmail={data.quoteEmail}
attachments={data.attachments}
/>
);
}
export default function OrdersPage() {
const router = useRouter();
const [orders, setOrders] = useState<OrderWithItems[]>([]);
@ -87,6 +233,8 @@ export default function OrdersPage() {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
/** Per-row: order.id → show all items (vs tab-filtered items)? */
const [showAllByOrder, setShowAllByOrder] = useState<Set<string>>(new Set());
/** ID of the order whose review modal is open. null = no modal. */
const [reviewOrderId, setReviewOrderId] = useState<string | null>(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)}
/>
))}
</div>
@ -348,6 +500,17 @@ export default function OrdersPage() {
</Button>
</div>
)}
{reviewOrderId && (
<OrdersReviewModalMount
key={reviewOrderId}
orderId={reviewOrderId}
onClose={closeReview}
onSaved={() => {
closeReview();
fetchOrders();
}}
/>
)}
</div>
);
}
@ -365,6 +528,7 @@ function OrderCard({
onToggleExpand,
onToggleShowAll,
onOpen,
onOpenReview,
}: {
order: import("@/lib/types").Order;
items: OrderItem[];
@ -374,6 +538,7 @@ function OrderCard({
onToggleExpand: () => void;
onToggleShowAll: () => void;
onOpen: () => void;
onOpenReview: () => void;
}) {
const complete = isOrderComplete(items);
// Tabs accent — thin colored rail on the left of the card so the
@ -568,7 +733,25 @@ function OrderCard({
the expand. stopPropagation is the belt-and-braces guard
in case the click bubbles up to the wrapping div in any
future refactor. */}
<div className="flex items-center pr-3">
<div className="flex items-center pr-3 gap-1">
{/* Warn-amber review button — opens the review modal.
Placed LEFT of the existing ExternalLink so the eye reads
"review → open" left-to-right. */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-warn-foreground hover:text-warn-foreground hover:bg-warn/10"
onClick={(e) => {
e.stopPropagation();
onOpenReview();
}}
aria-label={`Review order ${order.order_number}`}
title={`Review ${order.order_number} against email & attachments`}
data-testid="review-button"
>
<AlertTriangle className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"

View file

@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const updateOrderReviewFields = vi.fn();
const updateOrderItemsPrices = vi.fn();
vi.mock("@/lib/server/orders-service", () => ({
updateOrderReviewFields: (...args: unknown[]) =>
updateOrderReviewFields(...args),
updateOrderItemsPrices: (...args: unknown[]) =>
updateOrderItemsPrices(...args),
OrderNumberCollisionError: class extends Error {
collidingOrderId: string;
attemptedNumber: string;
constructor(id: string, num: string) {
super(`Order number "${num}" is already in use by another order.`);
this.name = "OrderNumberCollisionError";
this.collidingOrderId = id;
this.attemptedNumber = num;
}
},
}));
import { POST } from "./route";
import { OrderNumberCollisionError } from "@/lib/server/orders-service";
function postJSON(body: unknown) {
return new NextRequest(
"http://localhost/api/v1/orders/order-1/review-save",
{
method: "POST",
body: JSON.stringify(body),
},
);
}
describe("POST /api/v1/orders/[id]/review-save", () => {
beforeEach(() => {
updateOrderReviewFields.mockReset();
updateOrderItemsPrices.mockReset();
});
it("400s when body is not JSON", async () => {
const req = new NextRequest(
"http://localhost/api/v1/orders/order-1/review-save",
{ method: "POST", body: "not json" },
);
const res = await POST(req, { params: Promise.resolve({ id: "order-1" }) });
expect(res.status).toBe(400);
});
it("400s when body is null", async () => {
const res = await POST(postJSON(null), {
params: Promise.resolve({ id: "order-1" }),
});
expect(res.status).toBe(400);
});
it("200s with empty body (no-op)", async () => {
updateOrderReviewFields.mockResolvedValueOnce({
id: "order-1",
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const res = await POST(postJSON({}), {
params: Promise.resolve({ id: "order-1" }),
});
expect(res.status).toBe(200);
expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {});
const body = await res.json();
expect(body.order.id).toBe("order-1");
expect(body.items.updated).toBe(0);
});
it("calls updateOrderReviewFields with order_number when supplied", async () => {
updateOrderReviewFields.mockResolvedValueOnce({
id: "order-1",
order_number: "ORD-2026-07-014",
customer: "cust-1",
});
updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] });
const res = await POST(
postJSON({ order_number: "ORD-2026-07-014" }),
{ params: Promise.resolve({ id: "order-1" }) },
);
expect(res.status).toBe(200);
expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {
order_number: "ORD-2026-07-014",
});
});
it("calls updateOrderReviewFields with customer when supplied", async () => {
updateOrderReviewFields.mockResolvedValueOnce({
id: "order-1",
order_number: "ORD-2026-07-001",
customer: "cust-X",
});
updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] });
const res = await POST(
postJSON({ customer: "cust-X" }),
{ params: Promise.resolve({ id: "order-1" }) },
);
expect(res.status).toBe(200);
expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {
customer: "cust-X",
});
});
it("returns 409 on OrderNumberCollisionError with collidingOrderId", async () => {
updateOrderReviewFields.mockRejectedValueOnce(
new OrderNumberCollisionError("order-collide", "ORD-2026-07-014"),
);
const res = await POST(
postJSON({ order_number: "ORD-2026-07-014" }),
{ params: Promise.resolve({ id: "order-1" }) },
);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toBe("order_number_collision");
expect(body.collidingOrderId).toBe("order-collide");
expect(body.attemptedNumber).toBe("ORD-2026-07-014");
});
it("returns 404 when PB throws ClientResponseError(404)", async () => {
const err = Object.assign(new Error("not found"), { status: 404 });
updateOrderReviewFields.mockRejectedValueOnce(err);
const res = await POST(postJSON({}), {
params: Promise.resolve({ id: "order-x" }),
});
expect(res.status).toBe(404);
});
it("calls updateOrderItemsPrices when items are supplied", async () => {
updateOrderReviewFields.mockResolvedValueOnce({
id: "order-1",
order_number: "ORD-2026-07-014",
customer: "cust-1",
});
updateOrderItemsPrices.mockResolvedValueOnce({
updated: 2,
failed: [],
});
const res = await POST(
postJSON({
order_number: "ORD-2026-07-014",
items: [
{ id: "item-1", quantity: 5 },
{ id: "item-2", selling_price: 99 },
],
}),
{ params: Promise.resolve({ id: "order-1" }) },
);
expect(res.status).toBe(200);
expect(updateOrderItemsPrices).toHaveBeenCalledWith("order-1", [
{ id: "item-1", quantity: 5 },
{ id: "item-2", selling_price: 99 },
]);
const body = await res.json();
expect(body.items.updated).toBe(2);
expect(body.items.failed).toEqual([]);
});
it("returns 500 on unexpected service error", async () => {
updateOrderReviewFields.mockRejectedValueOnce(new Error("boom"));
const res = await POST(postJSON({ order_number: "X" }), {
params: Promise.resolve({ id: "order-1" }),
});
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/boom/);
});
});

View file

@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import {
OrderNumberCollisionError,
updateOrderItemsPrices,
updateOrderReviewFields,
} from "@/lib/server/orders-service";
/**
* POST /api/v1/orders/[id]/review-save
*
* Atomically commits the operator's review-time edits to the order
* header (order_number, customer) and any line-item price/qty
* patches. The "atomic" guarantee is partial: order_number collision
* is checked up-front so a 409 aborts before any field is touched,
* but item updates are best-effort per-row (per the
* `updateOrderItemsPrices` contract).
*
* Browser-callable: the operator's browser session is trusted via
* PB's own auth model (the user must be logged into WFDASH to reach
* the review modal). The route uses admin PB for writes because it
* runs server-side as a Server Route — the admin client never
* reaches the browser bundle. We do NOT call `assertAgentAuth` here:
* that helper is for the agent API (`/api/v1/*` routes intended for
* external integrations, identified by `Authorization: Bearer
* <AGENT_API_KEY>`). The modal is internal UI and would always 401
* under agent auth.
*
* Request body:
* {
* order_number?: string;
* customer?: string;
* items?: Array<{
* id: string;
* quantity?: number | null;
* selling_price?: number | null;
* cost_price?: number | null;
* }>;
* }
*
* Responses:
* 200 — saved. Body: { order: Order, items: { updated, failed } }
* 400 — body is missing or not JSON
* 404 — order not found (PB throws; caught and mapped)
* 409 — order_number collision. Body: { error, collidingOrderId, attemptedNumber }
* 500 — anything else
*/
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) {
const { id: orderId } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Body must be an object" }, { status: 400 });
}
const { order_number, customer, items } = body as {
order_number?: string;
customer?: string;
items?: Array<{
id: string;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
}>;
};
try {
// Header patch — order_number + customer are bundled because the
// collision check needs to fire BEFORE any items[] work runs.
// We always go through `updateOrderReviewFields` — even when the
// patch is empty — so the no-op path returns the current row and
// the route doesn't need a second PB import branch.
const headerPatch: { order_number?: string; customer?: string } = {};
if (typeof order_number === "string") headerPatch.order_number = order_number;
if (typeof customer === "string") headerPatch.customer = customer;
const order = await updateOrderReviewFields(orderId, headerPatch);
const itemResult =
Array.isArray(items) && items.length > 0
? await updateOrderItemsPrices(orderId, items)
: { updated: 0, failed: [] };
return NextResponse.json({ order, items: itemResult });
} catch (err) {
if (err instanceof OrderNumberCollisionError) {
const collidingOrderId =
(err as Error & { collidingOrderId?: string }).collidingOrderId ?? "";
const attemptedNumber =
(err as Error & { attemptedNumber?: string }).attemptedNumber ?? "";
return NextResponse.json(
{
error: "order_number_collision",
collidingOrderId,
attemptedNumber,
},
{ status: 409 },
);
}
const message = err instanceof Error ? err.message : "Save failed";
const status =
(err as Error & { status?: number }).status === 404 ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,429 @@
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" }),
);
expect(
screen.getByRole("link", { name: /open existing/i }),
).toHaveAttribute("href", "/orders/order-other");
});
it("lock pattern: editing cost recomputes selling and flips lock to 'cost'", () => {
render(<OrdersReviewModal {...baseProps()} />);
// baseItems: cost=25, selling=50, markup=100 → initial lock="cost".
// Edit cost from 25 → 40 (markup=100). Expected: selling = round2(40*2) = 80.
const costInputs = screen.getAllByDisplayValue("25");
expect(costInputs).toHaveLength(1);
fireEvent.change(costInputs[0], { target: { value: "40" } });
// Selling should now show 80.
expect(screen.getByDisplayValue("80")).toBeInTheDocument();
expect(screen.queryByDisplayValue("50")).not.toBeInTheDocument();
});
it("lock pattern: editing selling recomputes cost and flips lock to 'selling'", () => {
render(<OrdersReviewModal {...baseProps()} />);
// baseItems: cost=25, selling=50, markup=100 → initial lock="cost".
// Edit selling from 50 → 100 (markup=100). Expected: cost = round2(100/2) = 50.
const sellingInputs = screen.getAllByDisplayValue("50");
expect(sellingInputs).toHaveLength(1);
fireEvent.change(sellingInputs[0], { target: { value: "100" } });
// Cost should now show 50.
expect(screen.getByDisplayValue("50")).toBeInTheDocument();
expect(screen.queryByDisplayValue("25")).not.toBeInTheDocument();
});
it("lock pattern: editing markup with lock='selling' recomputes cost", () => {
render(<OrdersReviewModal {...baseProps()} />);
// baseItems: cost=25, selling=50, markup=100 → initial lock="cost".
// To exercise the markup-edit-costh-recompute branch, first flip
// the lock to "selling" by editing the selling price (which
// recomputes cost from markup, then sets lock="selling").
// selling 50 → 100, cost recomputes from 25 → 50, lock="selling".
fireEvent.change(screen.getByDisplayValue("50"), {
target: { value: "100" },
});
// After: cost=50, selling=100, markup=100, lock="selling".
// Now edit markup from 100 → 200. With lock="selling", cost
// recomputes: cost = round2(100 / (1 + 200/100)) = round2(100/3) = 33.33.
// After the previous selling edit, both the markup and selling
// inputs display "100" — pick the markup input (index 2 of the
// per-row input array: qty/cost/markup/selling) by scoping to the
// list item for "HSS drill 6mm".
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
const rowInputs = itemRow.querySelectorAll("input");
// rowInputs: [qty, cost, markup, selling]
fireEvent.change(rowInputs[2], { target: { value: "200" } });
// Cost should now show 33.33.
expect(screen.getByDisplayValue("33.33")).toBeInTheDocument();
});
it("renders PDF preview via iframe when first attachment is a PDF", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "Acme-RFQ.pdf",
file: ["acme-token"],
content_type: "application/pdf",
size_bytes: 1000,
email_message_id: "",
kind: "pdf",
},
]}
/>,
);
const iframe = document.querySelector("iframe");
expect(iframe).toBeInTheDocument();
expect(iframe?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-1/acme-token",
);
});
it("renders image preview via <img> when first attachment is an image", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "site-photo.jpg",
file: ["photo-token"],
content_type: "image/jpeg",
size_bytes: 1000,
email_message_id: "",
kind: "image",
},
]}
/>,
);
const img = document.querySelector("img");
expect(img).toBeInTheDocument();
expect(img?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-1/photo-token",
);
});
it("renders 'no preview' list when attachments are not PDF/image", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-1",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "doc.docx",
file: ["docx-token"],
content_type:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size_bytes: 1000,
email_message_id: "",
kind: "doc",
},
]}
/>,
);
expect(screen.getByText(/no preview/i)).toBeInTheDocument();
expect(screen.getByText("doc.docx")).toBeInTheDocument();
});
it("PDF wins over image when attachments are mixed [pdf, image]", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-pdf",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "Acme-RFQ.pdf",
file: ["pdf-token"],
content_type: "application/pdf",
size_bytes: 1000,
email_message_id: "",
kind: "pdf",
},
{
id: "att-img",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "site-photo.jpg",
file: ["photo-token"],
content_type: "image/jpeg",
size_bytes: 1000,
email_message_id: "",
kind: "image",
},
]}
/>,
);
const iframe = document.querySelector(
'[data-testid="review-attachment-iframe"]',
);
expect(iframe).toBeInTheDocument();
expect(iframe?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-pdf/pdf-token",
);
expect(document.querySelector('[data-testid="review-attachment-img"]')).toBeNull();
});
it("image renders with a '+N unpreviewable' hint when mixed [image, doc]", () => {
render(
<OrdersReviewModal
{...baseProps()}
attachments={[
{
id: "att-img",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "site-photo.jpg",
file: ["photo-token"],
content_type: "image/jpeg",
size_bytes: 1000,
email_message_id: "",
kind: "image",
},
{
id: "att-doc",
created: "",
updated: "",
collectionId: "coll-1",
collectionName: "quote_attachments",
quote: "quote-1",
filename: "doc.docx",
file: ["docx-token"],
content_type:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size_bytes: 1000,
email_message_id: "",
kind: "doc",
},
]}
/>,
);
const img = document.querySelector(
'[data-testid="review-attachment-img"]',
);
expect(img).toBeInTheDocument();
expect(img?.getAttribute("src")).toBe(
"/api/v1/files/coll-1/att-img/photo-token",
);
expect(screen.getByText(/unpreviewable attachment/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,756 @@
"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 | null;
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) {
// Persisted baseline — what the server currently holds. Internal
// state, NOT the prop, so a partial-failure save can rebase here
// (e.g. so the dirty check treats already-saved fields as clean,
// and a retry doesn't resend rows that committed).
const [persistedOrder, setPersistedOrder] = useState(order);
const [persistedItems, setPersistedItems] = useState(items);
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)]),
),
);
// (initial drafts seeded from the prop; the effect on open re-seeds
// from persistedItems — same data, just explicit.)
const [numberError, setNumberError] = useState<string | null>(null);
const [collidingOrderId, setCollidingOrderId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Per-item failure detail from the most recent partial-failure
// save. The list of IDs is also rendered next to the items table.
const [partialFailures, setPartialFailures] = useState<
Array<{ id: string; error: string }>
>([]);
// 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;
setPersistedOrder(order);
setPersistedItems(items);
setOrderNumber(order.order_number);
setCustomerId(order.customer);
setItemDrafts(
Object.fromEntries(items.map((it) => [it.id, initialItemDraft(it)])),
);
setNumberError(null);
setCollidingOrderId(null);
setPartialFailures([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, order.id]);
const dirty = useMemo(() => {
if (orderNumber !== persistedOrder.order_number) return true;
if (customerId !== persistedOrder.customer) return true;
for (const it of persistedItems) {
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;
// NOTE: markup is deliberately not compared here. The save body only
// carries quantity/selling_price/cost_price; the service layer
// recomputes markup server-side from the selling/cost pair. A
// markup-only edit is impossible anyway — onMarkupChange always
// rewrites the lock partner, which *is* part of the dirty body.
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderNumber, customerId, itemDrafts, persistedOrder.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 !== persistedOrder.order_number) body.order_number = orderNumber;
if (customerId !== persistedOrder.customer) body.customer = customerId;
const dirtyItems = persistedItems
.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()) as {
order?: typeof order;
items?: { updated: number; failed: Array<{ id: string; error: string }> };
};
// Update the persisted baseline with the freshly-returned order
// so the dirty check + future saves treat the saved fields as
// clean (and don't resend them on retry).
if (result.order) {
setPersistedOrder(result.order);
}
if (result.items?.failed?.length) {
const failedIds = new Set(result.items.failed.map((f) => f.id));
setPartialFailures(result.items.failed);
toast.error(
`Saved ${result.items.updated} items, ${result.items.failed.length} failed`,
);
// For successful items, sync the draft → persisted baseline so
// they no longer register as dirty (no double-send on retry).
// For failed items, keep the draft as-is — they'll keep
// showing up as dirty so the operator knows to retry/fix.
setItemDrafts((prev) => {
const next = { ...prev };
for (const it of persistedItems) {
if (!failedIds.has(it.id)) {
// No-op for clean rows: the draft already equals
// persistedItems.
const d = prev[it.id];
if (d) next[it.id] = { ...d };
}
}
return next;
});
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">
{/* Reflect the CURRENT selection, not the originally-
fetched `customer` prop — otherwise switching the
combobox leaves a stale label below the box. Fall
back to the prop when the lookup misses (defensive
— should never happen in normal flow since the
combobox only emits values from `customers`). */}
{(() => {
const selected = customers.find((c) => c.id === customerId);
const name = selected?.customer ?? customer.customer;
return name || "(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 || "(unknown)"}
</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">
{persistedOrder.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>
{persistedItems.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">
{persistedItems.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>
)}
{/* Partial-failure summary — only render when the most
recent save had row-level failures so the operator
can see which IDs to retry. Each ID is rendered with
the server's error string underneath. */}
{partialFailures.length > 0 && (
<div
className="mt-3 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs"
data-testid="review-partial-failures"
>
<p className="font-semibold text-destructive mb-1">
Failed to save {partialFailures.length} item
{partialFailures.length === 1 ? "" : "s"} — retry
just {partialFailures.length === 1 ? "this ID" : "these IDs"}:
</p>
<ul className="space-y-1">
{partialFailures.map((f) => (
<li
key={f.id}
className="flex items-start gap-1.5 text-destructive"
>
<span className="font-mono">{f.id}</span>
<span className="text-muted-foreground">
{f.error}
</span>
</li>
))}
</ul>
</div>
)}
</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 -----------------------------------------------
// Inline preview of the source attachment so the operator can scan
// the actual PDF/image against the order items in one screen.
// Falls back to the email body when no attachments exist.
function SourceOfTruth({
quoteEmail,
attachments,
}: {
quoteEmail: { subject: string; body: string };
attachments: QuoteAttachment[];
}) {
if (attachments.length === 0) {
return <EmailFallback quoteEmail={quoteEmail} />;
}
// Pick the first PDF if any, else first image, else the first
// entry of any other kind (which gets the "no preview" treatment).
const firstPdf = attachments.find((a) => a.kind === "pdf");
const firstImage = attachments.find((a) => a.kind === "image");
const firstOther = attachments.find(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
const previewTarget = firstPdf ?? firstImage ?? null;
const othersAreUnpreviewable = attachments.some(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
return (
<div className="rounded-md border bg-muted/30 text-xs space-y-3">
<div className="px-3 pt-3 flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length})
</div>
{/* Tab strip — DECORATIVE in v1 (no click handler, no activeId state).
Only the first previewable attachment (PDF > image) is rendered below;
the chips are visual context. Future v2 could wire them with
`useState<activeId>` inside `SourceOfTruth` to swap the preview. */}
{attachments.length > 1 && (
<div className="px-3 flex flex-wrap gap-1.5 border-b border-border/40 pb-2">
{attachments.map((a) => {
const isActive = previewTarget && a.id === previewTarget.id;
const previewable = a.kind === "pdf" || a.kind === "image";
return (
<span
key={a.id}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 leading-5 text-[10px]",
isActive
? "bg-primary/15 text-primary font-medium"
: "text-muted-foreground",
)}
title={`${a.filename} (${a.kind ?? "unknown"})`}
>
{previewable ? "●" : "○"} {a.filename}
</span>
);
})}
</div>
)}
{previewTarget && previewTarget.kind === "pdf" && (
<iframe
src={attachmentUrl(previewTarget)}
title={previewTarget.filename}
className="w-full h-[420px] bg-background border-t"
data-testid="review-attachment-iframe"
/>
)}
{previewTarget && previewTarget.kind === "image" && (
<div className="px-3 pb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={attachmentUrl(previewTarget)}
alt={previewTarget.filename}
className="max-w-full max-h-[420px] rounded border bg-background"
data-testid="review-attachment-img"
/>
</div>
)}
{/* No previewable attachment at all — show a no-preview message. */}
{!previewTarget && firstOther && (
<div className="px-3 pb-3 space-y-1">
<p
className="text-muted-foreground italic"
data-testid="review-no-preview"
>
{`No preview available for this ${firstOther.kind ?? "file"} type.`}
</p>
</div>
)}
{othersAreUnpreviewable && previewTarget && (
<div className="px-3 pb-3">
<p className="text-[10px] text-muted-foreground">
+ {attachments.filter(
(a) => a.kind !== "pdf" && a.kind !== "image",
).length}{" "}
unpreviewable attachment(s) — open to view.
</p>
</div>
)}
<ul className="px-3 pb-3 space-y-1">
{attachments.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2 text-[11px]"
>
<span className="truncate text-muted-foreground">
{a.filename}
</span>
<a
href={attachmentUrl(a)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
open <ExternalLink className="h-2.5 w-2.5" />
</a>
</li>
))}
</ul>
</div>
);
}
function EmailFallback({
quoteEmail,
}: {
quoteEmail: { subject: string; body: string };
}) {
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-[420px] overflow-y-auto">
{quoteEmail.body || "(no body)"}
</pre>
</div>
);
}
function attachmentUrl(a: QuoteAttachment): string {
const token = Array.isArray(a.file) ? a.file[0] : a.file;
return `/api/v1/files/${a.collectionId}/${a.id}/${token}`;
}

View file

@ -0,0 +1,630 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const { store, pbMock, clearStore } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
clauses.every((clause) => {
const m = clause.match(/^(\w+)\s*([!=]+)\s*"?([^"]+)"?$/);
if (!m) return true;
const [, field, op, raw] = m;
const value = raw.replace(/^"|"$/g, "");
const actual = String(item[field] ?? "");
if (op === "=") return actual === value;
if (op === "!=") return actual !== value;
return true;
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
_page = 1,
_perPage = 25,
opts: { filter?: string } = {},
) {
const items = where(name, opts.filter || "");
return { items, totalItems: items.length, page: 1, perPage: _perPage };
},
async getFullList() {
return where(name, "");
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
existing[key] = value;
}
existing.updated = new Date().toISOString();
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import {
OrderNumberCollisionError,
updateOrderItemsPrices,
updateOrderReviewFields,
} from "./orders-service";
beforeEach(() => clearStore());
describe("updateOrderReviewFields", () => {
it("updates order_number when unique", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-014",
});
expect(result.order_number).toBe("ORD-2026-07-014");
});
it("updates customer", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
customer: "cust-new",
});
expect(result.customer).toBe("cust-new");
});
it("updates both in a single call", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-014",
customer: "cust-new",
});
expect(result.order_number).toBe("ORD-2026-07-014");
expect(result.customer).toBe("cust-new");
});
it("throws OrderNumberCollisionError when another order owns the number", async () => {
await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-002",
customer: "cust-2",
});
await expect(
updateOrderReviewFields(order.id, { order_number: "ORD-2026-07-001" }),
).rejects.toBeInstanceOf(OrderNumberCollisionError);
await expect(
updateOrderReviewFields(order.id, { order_number: "ORD-2026-07-001" }),
).rejects.toMatchObject({
collidingOrderId: expect.any(String),
attemptedNumber: "ORD-2026-07-001",
});
});
it("does not throw when the new number equals the order's own current number", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
// Same number on the same order should be a no-op, not a self-collision.
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-001",
});
expect(result.order_number).toBe("ORD-2026-07-001");
});
it("no-ops when patch is empty", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const result = await updateOrderReviewFields(order.id, {});
expect(result.order_number).toBe("ORD-2026-07-001");
expect(result.customer).toBe("cust-1");
});
it("escapes embedded quotes in order_number before building the collision filter", async () => {
// An order_number containing `"` would break the filter
// expression unless we escape embedded quotes before
// interpolating. Spy on getList to capture the filter
// the service actually sends.
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const originalCollection = pbMock.collection;
const captured: string[] = [];
pbMock.collection = (name: string) => {
const col = originalCollection(name);
if (name === "orders") {
const originalGetList = col.getList;
col.getList = vi.fn(async (...args: unknown[]) => {
const opts = (args[2] as { filter?: string } | undefined) || {};
if (opts.filter) captured.push(opts.filter);
return originalGetList(...(args as Parameters<typeof col.getList>));
});
}
return col;
};
try {
// Pick an order_number that contains a literal quote. The
// unescaped filter would be `order_number = "ORD "foo" && id != "..."`,
// which would either fail to parse or match the wrong rows.
await updateOrderReviewFields(order.id, {
order_number: 'ORD "weird"',
});
expect(captured.length).toBeGreaterThan(0);
// The filter MUST escape the `"` inside the literal — look for
// the backslash-escaped quote in the captured string.
const filter = captured[captured.length - 1];
expect(filter).toContain('ORD \\"weird\\"');
} finally {
pbMock.collection = originalCollection;
}
});
it("maps a PB unique-constraint write error back to OrderNumberCollisionError", async () => {
// Two callers race past the pre-write check; the loser's update
// then throws on the unique index. The service should catch that
// and surface a structured collision error (with the colliding
// row's id) rather than letting it bubble up as a 500.
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
await pbMock.collection("orders").create({
order_number: "ORD-2026-07-002",
customer: "cust-2",
});
const originalCollection = pbMock.collection;
pbMock.collection = (name: string) => {
const col = originalCollection(name);
if (name === "orders") {
col.update = vi.fn(async () => {
const err = new Error(
"UNIQUE constraint failed: orders.order_number",
);
throw err;
});
}
return col;
};
try {
await expect(
updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-002",
}),
).rejects.toBeInstanceOf(OrderNumberCollisionError);
} finally {
pbMock.collection = originalCollection;
}
});
});
describe("updateOrderItemsPrices", () => {
it("updates quantity, selling_price, and cost_price for each item", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-2",
order: order.id,
description: "Bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-1", quantity: 5, selling_price: 60 },
{ id: "item-2", quantity: 3 },
]);
expect(result.updated).toBe(2);
expect(result.failed).toEqual([]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.quantity).toBe(5);
expect(item1.selling_price).toBe(60);
// markup should have been recomputed from cost/selling: (60-25)/25*100 = 140
expect(item1.markup).toBe(140);
const item2 = await pbMock.collection("order_items").getOne("item-2");
expect(item2.quantity).toBe(3);
expect(item2.selling_price).toBe(10); // unchanged
});
it("passes markup through when explicitly supplied", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await updateOrderItemsPrices(order.id, [
{ id: "item-1", markup: 75 },
]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.markup).toBe(75);
});
it("returns partial failure when one item update throws", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-good",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
// item-missing does NOT exist in the store.
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-good", quantity: 5 },
{ id: "item-missing", quantity: 1 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([
{ id: "item-missing", error: expect.stringMatching(/not found/) },
]);
const good = await pbMock.collection("order_items").getOne("item-good");
expect(good.quantity).toBe(5);
});
it("returns zero updated for empty array", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
const result = await updateOrderItemsPrices(order.id, []);
expect(result.updated).toBe(0);
expect(result.failed).toEqual([]);
});
it("skips pb.update when the resolved patch is empty (no field changes)", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-noop",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-does",
order: order.id,
description: "Bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
// Spy on update to confirm the no-op row doesn't trigger a write.
// `pbMock.collection(name)` returns a fresh object each call, so we
// wrap the `collection` factory itself and intercept at every call.
const originalCollection = pbMock.collection;
const updateCalls: Array<{ id: string; data: Record<string, unknown> }> = [];
pbMock.collection = (name: string) => {
const col = originalCollection(name);
if (name === "order_items") {
const originalUpdate = col.update;
col.update = vi.fn(async (id: string, data: Record<string, unknown>) => {
updateCalls.push({ id, data });
return originalUpdate(id, data);
});
}
return col;
};
try {
const result = await updateOrderItemsPrices(order.id, [
// No field changes — caller's patch has no quantity/price/markup.
{ id: "item-noop" },
// Real change — should issue an update.
{ id: "item-does", quantity: 7 },
]);
// Only the row with an actual change counts as updated.
expect(result.updated).toBe(1);
expect(result.failed).toEqual([]);
// The no-op row did NOT receive a pb.update call.
const noopCalls = updateCalls.filter((c) => c.id === "item-noop");
expect(noopCalls).toEqual([]);
// The real row did.
const doesCalls = updateCalls.filter((c) => c.id === "item-does");
expect(doesCalls).toHaveLength(1);
expect(doesCalls[0].data).toEqual({ quantity: 7 });
// The row's quantity was actually written.
const item = await pbMock.collection("order_items").getOne("item-does");
expect(item.quantity).toBe(7);
} finally {
pbMock.collection = originalCollection;
}
});
it("writes markup_percent alongside markup when selling_price is supplied", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-mp",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
// Start with stale markup_percent to confirm it gets overwritten.
markup: 100,
markup_percent: 0,
});
const result = await updateOrderItemsPrices(order.id, [
// selling_price 60, cost_price unchanged at 25 → markup = (60-25)/25*100 = 140
{ id: "item-mp", selling_price: 60 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([]);
const item = await pbMock.collection("order_items").getOne("item-mp");
expect(item.selling_price).toBe(60);
expect(item.markup).toBe(140);
// markup_percent must mirror markup — the two fields stay in lock-step.
expect(item.markup_percent).toBe(140);
});
it("rejects items that belong to a different order", async () => {
// Two orders, each with its own item.
const orderA = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
const orderB = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-002",
});
await pbMock.collection("order_items").create({
id: "item-a",
order: orderA.id,
description: "A's drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-b",
order: orderB.id,
description: "B's bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
// Caller targets order A but slips in order B's item — the cross-
// order check must catch it and not modify item-b.
const result = await updateOrderItemsPrices(orderA.id, [
{ id: "item-a", quantity: 7 },
{ id: "item-b", quantity: 99 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([
{ id: "item-b", error: "Item does not belong to this order" },
]);
const itemA = await pbMock.collection("order_items").getOne("item-a");
expect(itemA.quantity).toBe(7);
const itemB = await pbMock.collection("order_items").getOne("item-b");
expect(itemB.quantity).toBe(1); // unchanged
});
});
describe("updateOrderItemsPrices", () => {
it("updates quantity, selling_price, and cost_price for each item", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await pbMock.collection("order_items").create({
id: "item-2",
order: order.id,
description: "Bit",
quantity: 1,
selling_price: 10,
cost_price: 5,
markup: 100,
});
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-1", quantity: 5, selling_price: 60 },
{ id: "item-2", quantity: 3 },
]);
expect(result.updated).toBe(2);
expect(result.failed).toEqual([]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.quantity).toBe(5);
expect(item1.selling_price).toBe(60);
// markup should have been recomputed from cost/selling: (60-25)/25*100 = 140
expect(item1.markup).toBe(140);
const item2 = await pbMock.collection("order_items").getOne("item-2");
expect(item2.quantity).toBe(3);
expect(item2.selling_price).toBe(10); // unchanged
});
it("passes markup through when explicitly supplied", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-1",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
await updateOrderItemsPrices(order.id, [
{ id: "item-1", markup: 75 },
]);
const item1 = await pbMock.collection("order_items").getOne("item-1");
expect(item1.markup).toBe(75);
});
it("returns partial failure when one item update throws", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
await pbMock.collection("order_items").create({
id: "item-good",
order: order.id,
description: "Drill",
quantity: 2,
selling_price: 50,
cost_price: 25,
markup: 100,
});
// item-missing does NOT exist in the store.
const result = await updateOrderItemsPrices(order.id, [
{ id: "item-good", quantity: 5 },
{ id: "item-missing", quantity: 1 },
]);
expect(result.updated).toBe(1);
expect(result.failed).toEqual([
{ id: "item-missing", error: expect.stringMatching(/not found/) },
]);
const good = await pbMock.collection("order_items").getOne("item-good");
expect(good.quantity).toBe(5);
});
it("returns zero updated for empty array", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
});
const result = await updateOrderItemsPrices(order.id, []);
expect(result.updated).toBe(0);
expect(result.failed).toEqual([]);
});
});

View file

@ -265,3 +265,205 @@ async function getMaxOrderSequenceInMonth(
function formatOrderNumber(year: number, month: number, seq: number): string {
return `ORD-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
}
/**
* Thrown by `updateOrderReviewFields` when the requested `order_number`
* is already in use by a different order. Carries the colliding
* order's id so the route handler can include it in the 409 response
* (the modal renders it as a link to the conflicting order).
*/
export class OrderNumberCollisionError extends Error {
readonly collidingOrderId: string;
readonly attemptedNumber: string;
constructor(collidingOrderId: string, attemptedNumber: string) {
super(
`Order number "${attemptedNumber}" is already in use by another order.`,
);
this.name = "OrderNumberCollisionError";
this.collidingOrderId = collidingOrderId;
this.attemptedNumber = attemptedNumber;
}
}
/**
* Patches the order header fields editable from the review modal.
* Currently supports `order_number` and `customer`. Validates
* `order_number` uniqueness against the `orders` collection
* (excluding self) before write; throws
* `OrderNumberCollisionError` on collision.
*/
export async function updateOrderReviewFields(
orderId: string,
patch: { order_number?: string; customer?: string },
) {
const pb = await getAdminPocketBase();
// Validate order_number uniqueness before write — we do this as a
// separate query rather than letting PB throw on its unique index
// so we can return the colliding row's id to the UI (PB's own
// 400 doesn't carry it).
if (patch.order_number !== undefined) {
const existing = await pb
.collection(ORDER_COLLECTION)
.getOne(orderId);
if (existing.order_number !== patch.order_number) {
// Escape embedded quotes before interpolating into the PB
// filter — an order_number containing `"` would otherwise break
// the filter expression (or worse, alter it).
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
const escapedId = orderId.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string; order_number: string }>(1, 1, {
filter: `order_number = "${escapedNumber}" && id != "${escapedId}"`,
});
if (conflict.items.length > 0) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
patch.order_number,
);
}
}
}
// Build a clean patch object with only the defined keys — passing
// `undefined` to PB is harmless but we keep the wire payload tight.
const cleanPatch: Record<string, unknown> = {};
if (patch.order_number !== undefined) cleanPatch.order_number = patch.order_number;
if (patch.customer !== undefined) cleanPatch.customer = patch.customer;
if (Object.keys(cleanPatch).length === 0) {
// No-op — just re-fetch and return.
return pb.collection(ORDER_COLLECTION).getOne(orderId);
}
try {
return await pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch);
} catch (err) {
// Catch the unique-index race: two callers can both pass the
// pre-write collision check above, then race to commit. The loser's
// update then fails on the unique index. Map that to the same
// structured error class so the route can still 409 — and re-
// query to surface the colliding row's id (PB's native error
// string doesn't carry it).
if (err && /UNIQUE constraint failed/i.test(String(err))) {
if (patch.order_number !== undefined) {
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(1, 1, {
filter: `order_number = "${escapedNumber}"`,
});
if (conflict.items[0]) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
patch.order_number,
);
}
}
}
throw err;
}
}
/**
* Patches price/qty fields on the order's line items in a non-
* transactional batch. Each item is PATCHed individually; per-row
* failures are accumulated into `failed` rather than aborting the
* batch, so the operator sees which lines couldn't save and can
* retry just those.
*
* Markup computation: if the caller supplies `selling_price` OR
* `cost_price` (but not both) and we know the other from the
* current row, we recompute `markup` from the supplied + known
* values. If the caller supplies `markup` explicitly, we pass it
* through verbatim.
*/
export async function updateOrderItemsPrices(
orderId: string,
items: Array<{
id: string;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
markup?: number | null;
}>,
): Promise<{
updated: number;
failed: Array<{ id: string; error: string }>;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (items.length === 0) return { updated: 0, failed: [] };
const updated: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
for (const item of items) {
try {
// Read the current row so we can compute markup when only
// one of selling_price/cost_price is supplied.
const current = await pb.collection(orderItemsCollection).getOne(item.id);
// Reject items that don't belong to the requested order. Without
// this, a request to order A can include an item ID from order B
// and modify it — a serious cross-order write vulnerability.
if (current.order !== orderId) {
failed.push({
id: item.id,
error: "Item does not belong to this order",
});
continue;
}
const patch: Record<string, unknown> = {};
if (item.quantity !== undefined) patch.quantity = item.quantity;
if (item.selling_price !== undefined) patch.selling_price = item.selling_price;
if (item.cost_price !== undefined) patch.cost_price = item.cost_price;
// Markup computation:
// - explicit markup in patch → pass through
// - selling_price supplied, cost_price not → recompute from selling/current-cost
// - cost_price supplied, selling_price not → recompute from current-selling/cost
// - both supplied → recompute from the new pair
// - neither supplied, no explicit markup → leave current markup alone
//
// `markup_percent` mirrors `markup` (the two fields are kept in
// lock-step by `createOrderFromQuote`); write both together so
// the row never goes stale after a price edit.
if (item.markup !== undefined) {
patch.markup = item.markup;
patch.markup_percent = item.markup;
} else if (
item.selling_price !== undefined ||
item.cost_price !== undefined
) {
const newSelling = item.selling_price ?? current.selling_price;
const newCost = item.cost_price ?? current.cost_price;
const newMarkup =
Number(newCost) > 0
? Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100
: 0;
patch.markup = newMarkup;
patch.markup_percent = newMarkup;
}
// Empty patch: nothing to write. Skip the PB round-trip so we
// don't stamp `updated` and don't bump the row's `updated` ts.
if (Object.keys(patch).length === 0) {
continue;
}
await pb.collection(orderItemsCollection).update(item.id, patch);
updated.push(item.id);
} catch (err) {
failed.push({
id: item.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { updated: updated.length, failed };
}