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:
parent
139a3f3bfe
commit
a0afa51600
4 changed files with 430 additions and 191 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
167
src/components/orders-review-modal-mount.test.tsx
Normal file
167
src/components/orders-review-modal-mount.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
230
src/components/orders-review-modal-mount.tsx
Normal file
230
src/components/orders-review-modal-mount.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue