Merge feature/orders-review-modal: review-order-modal feature
This commit is contained in:
commit
aa00dacb47
8 changed files with 4804 additions and 1 deletions
2311
docs/superpowers/plans/2026-07-29-orders-review-modal.md
Normal file
2311
docs/superpowers/plans/2026-07-29-orders-review-modal.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
180
src/app/api/v1/orders/[id]/review-save/route.test.ts
Normal file
180
src/app/api/v1/orders/[id]/review-save/route.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
112
src/app/api/v1/orders/[id]/review-save/route.ts
Normal file
112
src/app/api/v1/orders/[id]/review-save/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
429
src/components/orders-review-modal.test.tsx
Normal file
429
src/components/orders-review-modal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
756
src/components/orders-review-modal.tsx
Normal file
756
src/components/orders-review-modal.tsx
Normal 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 &
|
||||
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}`;
|
||||
}
|
||||
630
src/lib/server/orders-service-review.test.ts
Normal file
630
src/lib/server/orders-service-review.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue