refactor(orders): replace OrdersReviewModal with OrdersReviewModalMount and streamline attachment handling

- Updated the orders page to use `OrdersReviewModalMount` for improved order review functionality.
- Removed the inline `orderAttachementsToQuotes` function to simplify the code structure and enhance maintainability.
- Introduced a review modal in the order detail page, allowing users to trigger the review process with a dedicated button.
- Ensured consistent behavior between the orders list and detail pages regarding order review interactions.
This commit is contained in:
Dave 2026-08-24 07:26:18 +02:00
parent 139a3f3bfe
commit a0afa51600
4 changed files with 430 additions and 191 deletions

View file

@ -18,6 +18,7 @@ import {
} from "@/components/ui/table";
import { DeleteDialog } from "@/components/delete-dialog";
import { CancelOrderDialog } from "@/components/cancel-order-dialog";
import { OrdersReviewModalMount } from "@/components/orders-review-modal-mount";
import {
ArrowLeft,
Trash2,
@ -25,6 +26,7 @@ import {
Mail,
FileText,
Ban,
AlertTriangle,
FileDown,
ClipboardEdit,
Inbox,
@ -107,6 +109,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
const [loading, setLoading] = useState(true);
const [deleteOpen, setDeleteOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
/** Review modal — gated by `reviewOpen` so the mount component
* only runs its fetch sequence when the operator actually asks
* for it. Same affordance as the orders-list page so the two
* surfaces behave identically. */
const [reviewOpen, setReviewOpen] = useState(false);
// ─── Quote-matching local state ──────────────────────────────
// The match attempt is keyed off the saved `quote_match_method` /
@ -420,6 +427,22 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
</div>
</div>
<div className="flex gap-2 shrink-0">
{/* Review — opens the shared review modal (same
affordance as the per-row button on /orders). Warn-amber
styling mirrors the list-page button so the eye reads
"this is the same workflow" between the two surfaces. */}
<Button
variant="outline"
size="sm"
onClick={() => setReviewOpen(true)}
className="text-warn border-warn/40 hover:bg-warn/10 hover:text-warn"
data-testid="review-order-button"
aria-label={`Review order ${order.order_number}`}
title={`Review ${order.order_number} against email & attachments`}
>
<AlertTriangle className="mr-2 h-4 w-4" />
Review
</Button>
<Button onClick={() => router.push(`/orders/${id}/process`)}>
<ClipboardEdit className="mr-2 h-4 w-4" />
Process Order
@ -915,6 +938,15 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
onConfirm={handleCancel}
/>
{reviewOpen && (
<OrdersReviewModalMount
key={order.id}
orderId={order.id}
onClose={() => setReviewOpen(false)}
onSaved={() => setReviewOpen(false)}
/>
)}
{/* ─── Regenerate PO modal ────────────────────────────────── */}
{/* Captures a reason before generating the next PDF version.
The reason is REQUIRED for v≥2 so the audit trail explains

View file

@ -40,7 +40,7 @@ import {
type TabGroup,
} from "@/lib/item-status-ui";
import { ItemStatusTabs } from "@/components/item-status-tabs";
import { OrdersReviewModal } from "@/components/orders-review-modal";
import { OrdersReviewModalMount } from "@/components/orders-review-modal-mount";
import { DuplicateOrdersDialog } from "@/components/duplicate-orders-dialog";
import type { OrderItem, ItemStatus } from "@/lib/types";
@ -109,196 +109,6 @@ async function fetchDashboardJson<T>(url: string): Promise<T> {
return body;
}
/**
* Convert the order's `order_attachements` file field into the
* `QuoteAttachment[]` shape the review modal expects. The PB file field
* stores bare filenames (no collection-id token), so we synthesise the
* `kind` / `content_type` pairs from the filename extension. `size_bytes`
* is not available from a PB file field, so we leave it at 0.
*
* Kept inline because it's tightly coupled to the modal's
* `QuoteAttachment` interface — extracting it would just push the same
* code into another file.
*/
function orderAttachementsToQuotes(
order: import("@/lib/types").Order,
): import("@/lib/types").QuoteAttachment[] {
const files = order.order_attachements ?? [];
return files.map((filename) => {
const lower = filename.toLowerCase();
let kind: "pdf" | "image" | "doc" = "doc";
let content_type = "application/octet-stream";
if (lower.endsWith(".pdf")) {
kind = "pdf";
content_type = "application/pdf";
} else if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lower)) {
const ext = lower.split(".").pop() ?? "";
const imageMime =
ext === "png"
? "png"
: ext === "gif"
? "gif"
: ext === "webp"
? "webp"
: ext === "svg"
? "svg+xml"
: ext === "bmp"
? "bmp"
: "jpeg";
kind = "image";
content_type = `image/${imageMime}`;
} else if (lower.endsWith(".docx")) {
content_type =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if (lower.endsWith(".txt")) {
content_type = "text/plain";
}
return {
id: order.id,
created: order.created,
updated: order.updated,
collectionId: order.collectionId,
collectionName: "orders",
quote: order.quote,
filename,
file: [filename],
content_type,
size_bytes: 0,
email_message_id: "",
kind,
};
});
}
/* ============================================================================
* 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: { sender: string; 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 review = await fetchDashboardJson<{
order: import("@/lib/types").Order;
items: OrderItem[];
customers: import("@/lib/types").Customer[];
quoteEmail: { sender: string; subject: string; body: string };
}>(`/api/v1/orders/${encodeURIComponent(orderId)}/review`);
if (cancelled) return;
const { order, items, customers, quoteEmail } = review;
// Source attachments from `order_attachements` — the multi-file
// field ON the order record itself (orders collection
// `pbc_3527180448`, `maxSelect: 10`, accepts any MIME). Older
// orders may still have attachments stored under
// `quote_attachments` on the parent quote, but those records
// were migrated to the order-level field on 2026-07-29, so
// reading from the order is authoritative going forward.
// The PB file field stores bare filenames (no collection-id
// token); we synthesise the QuoteAttachment shape the modal
// expects (kind + content_type) from the filename extension.
const attachments = orderAttachementsToQuotes(order);
if (cancelled) return;
// Customer fallback chain: expand → match by ID in the loaded list →
// first customer as a last resort. The "first customer" fallback
// is misleading (it's just the newest customer in PB, not anything
// related to the order), but it preserves the previous UX if the
// list happens to be empty after filtering.
const customer =
(order.expand?.customer as import("@/lib/types").Customer) ||
customers.find((c) => c.id === order.customer) ||
customers[0];
// Company can be null if the order's company record isn't loaded.
// The modal renders "(unknown)" in that case rather than fabricating
// a stub with the wrong ID.
const company =
(order.expand?.company as import("@/lib/types").Company) || null;
setData({
order,
items,
customer,
company,
customers,
quoteEmail,
attachments,
});
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load order");
}
}
})();
return () => {
cancelled = true;
};
}, [orderId]);
if (error) {
return (
<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();

View file

@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { OrdersReviewModalMount } from "./orders-review-modal-mount";
// The pocketbase auth token accessor is the only side effect the
// mount reaches for on the client. Stub it so the fetch picks up
// the same Authorization header as production.
vi.mock("@/lib/pocketbase", () => ({
default: () => ({ authStore: { token: "test-token" } }),
}));
const sampleReviewResponse = {
order: {
id: "order-1",
created: "2026-01-01T00:00:00.000Z",
updated: "2026-01-02T00:00:00.000Z",
collectionId: "abc",
collectionName: "orders",
order_number: "ORD-001",
customer: "cust-1",
company: "comp-1",
quote: "quote-1",
order_attachements: ["drawing.pdf", "photo.png"],
notes: "",
rep: "",
invoiced: false,
email_message_id: "",
expand: {
customer: {
id: "cust-1",
created: "",
updated: "",
collectionId: "abc",
collectionName: "customers",
customer: "J. Smith",
company: "comp-1",
},
company: {
id: "comp-1",
created: "",
updated: "",
collectionId: "abc",
collectionName: "companies",
company: "Acme",
},
},
},
items: [
{
id: "item-1",
created: "",
updated: "",
collectionId: "",
collectionName: "order_items",
order: "order-1",
description: "HSS drill",
po_description: "",
quantity: 2,
order_qty: 2,
selling_price: 50,
supplier: "sup-1",
cost_price: 25,
markup: 100,
purchase_order: "",
in_stock: false,
notes: "",
item_status: "pending",
},
],
customers: [
{
id: "cust-1",
created: "",
updated: "",
collectionId: "",
collectionName: "customers",
customer: "J. Smith",
company: "comp-1",
},
],
quoteEmail: {
sender: "buyer@example.com",
subject: "RFQ ORD-001",
body: "<p>Please quote</p>",
},
};
describe("OrdersReviewModalMount", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders the spinner while fetching", () => {
vi.spyOn(global, "fetch").mockImplementation(
() => new Promise<Response>(() => {}) // never resolves
);
render(
<OrdersReviewModalMount
orderId="order-1"
onClose={() => {}}
onSaved={() => {}}
/>,
);
// The Loader2 component renders an SVG with the `animate-spin`
// class while the fetch is in flight.
expect(document.querySelector(".animate-spin")).toBeTruthy();
});
it("fetches /api/v1/orders/{id}/review with the bearer token", async () => {
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify(sampleReviewResponse), { status: 200 }),
);
render(
<OrdersReviewModalMount
orderId="order-1"
onClose={() => {}}
onSaved={() => {}}
/>,
);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"/api/v1/orders/order-1/review",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "test-token",
}),
}),
);
});
});
it("renders the OrdersReviewModal once the fetch resolves", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify(sampleReviewResponse), { status: 200 }),
);
render(
<OrdersReviewModalMount
orderId="order-1"
onClose={() => {}}
onSaved={() => {}}
/>,
);
// The modal renders the order number into a read-only input.
// `getByDisplayValue` is the right matcher because the order
// number never appears as plain text in the DOM.
await waitFor(() => {
expect(screen.getByDisplayValue("ORD-001")).toBeInTheDocument();
});
});
it("renders an error card when the API fails", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "PB down" }), { status: 500 }),
);
render(
<OrdersReviewModalMount
orderId="order-1"
onClose={() => {}}
onSaved={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByText("PB down")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,230 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { OrdersReviewModal } from "@/components/orders-review-modal";
import type {
Company,
Customer,
Order,
OrderItem,
QuoteAttachment,
} from "@/lib/types";
/**
* Convert the order's `order_attachements` file field into the
* `QuoteAttachment[]` shape the review modal expects. The PB file field
* stores bare filenames (no collection-id token), so we synthesise the
* `kind` / `content_type` pairs from the filename extension. `size_bytes`
* is not available from a PB file field, so we leave it at 0.
*/
function orderAttachementsToQuotes(order: Order): QuoteAttachment[] {
const files = order.order_attachements ?? [];
return files.map((filename) => {
const lower = filename.toLowerCase();
let kind: "pdf" | "image" | "doc" = "doc";
let content_type = "application/octet-stream";
if (lower.endsWith(".pdf")) {
kind = "pdf";
content_type = "application/pdf";
} else if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lower)) {
const ext = lower.split(".").pop() ?? "";
const imageMime =
ext === "png"
? "png"
: ext === "gif"
? "gif"
: ext === "webp"
? "webp"
: ext === "svg"
? "svg+xml"
: ext === "bmp"
? "bmp"
: "jpeg";
kind = "image";
content_type = `image/${imageMime}`;
} else if (lower.endsWith(".docx")) {
content_type =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
} else if (lower.endsWith(".txt")) {
content_type = "text/plain";
}
return {
id: order.id,
created: order.created,
updated: order.updated,
collectionId: order.collectionId,
collectionName: "orders",
quote: order.quote,
filename,
file: [filename],
content_type,
size_bytes: 0,
email_message_id: "",
kind,
};
});
}
export interface OrdersReviewModalMountProps {
orderId: string;
onClose: () => void;
onSaved: () => void;
}
interface ReviewPayload {
order: Order;
items: OrderItem[];
customers: Customer[];
quoteEmail: { sender: string; subject: string; body: string };
}
interface ReviewData extends ReviewPayload {
// Resolved from the order's expand or the customer list (see the
// fallback chain in the fetch effect). Typed as non-null because
// we always produce one — the modal renders "(unknown)" for a
// missing `company` and a placeholder for a missing `customer`.
customer: Customer;
company: Company | null;
attachments: QuoteAttachment[];
}
/**
* Fetches the order's full details, line items, the source quote's
* email + attachments, and the customer/company lists, then renders
* `OrdersReviewModal`. Shared between `/orders` (list page) and
* `/orders/[id]` (detail page) so the same review workflow is
* available from either entry point.
*
* Lives outside `orders-review-modal.tsx` because the modal itself is
* a pure renderer — keeping the fetch sequence in a sibling component
* means the modal can be reused (e.g. in tests, or future embedded
* contexts) without dragging the data layer with it.
*/
export function OrdersReviewModalMount({
orderId,
onClose,
onSaved,
}: OrdersReviewModalMountProps) {
const [data, setData] = useState<ReviewData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const pbToken =
typeof window !== "undefined"
? // Lazy import via dynamic require to avoid pulling the
// pocketbase singleton into a server bundle — the
// modal-mount only ever runs client-side anyway, but
// the dependency graph keeps it cleaner this way.
(await import("@/lib/pocketbase")).default().authStore.token
: null;
const headers: HeadersInit = pbToken
? { Authorization: pbToken }
: {};
const res = await fetch(
`/api/v1/orders/${encodeURIComponent(orderId)}/review`,
{ headers, cache: "no-store" },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
typeof body?.error === "string"
? body.error
: `Request failed (${res.status})`,
);
}
const review = (await res.json()) as ReviewPayload;
if (cancelled) return;
const { order, items, customers, quoteEmail } = review;
// Source attachments from `order_attachements` — the multi-file
// field ON the order record itself (orders collection
// `pbc_3527180448`, `maxSelect: 10`, accepts any MIME). Older
// orders may still have attachments stored under
// `quote_attachments` on the parent quote, but those records
// were migrated to the order-level field on 2026-07-29, so
// reading from the order is authoritative going forward.
const attachments = orderAttachementsToQuotes(order);
if (cancelled) return;
// Customer fallback chain: expand → match by ID in the loaded list →
// first customer as a last resort. The "first customer" fallback
// is misleading (it's just the newest customer in PB, not anything
// related to the order), but it preserves the previous UX if the
// list happens to be empty after filtering.
const customer =
(order.expand?.customer as Customer | undefined) ||
customers.find((c) => c.id === order.customer) ||
customers[0];
// Company can be null if the order's company record isn't loaded.
// The modal renders "(unknown)" in that case rather than fabricating
// a stub with the wrong ID.
const company = (order.expand?.company as Company | undefined) || null;
setData({
order,
items,
customer,
company,
customers,
quoteEmail,
attachments,
});
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load order",
);
}
}
})();
return () => {
cancelled = true;
};
}, [orderId]);
if (error) {
return (
<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}
/>
);
}