feat(orders): warn-amber review button + modal mount on orders list

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

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 16:04:00 +02:00
parent f8784e3ba9
commit de87eb312e

View file

@ -16,6 +16,7 @@ import {
Link2,
Sparkles,
AlertCircle,
AlertTriangle,
CircleCheck,
Search,
X,
@ -38,6 +39,7 @@ import {
type TabGroup,
} from "@/lib/item-status-ui";
import { ItemStatusTabs } from "@/components/item-status-tabs";
import { OrdersReviewModal } from "@/components/orders-review-modal";
import type { OrderItem, ItemStatus } from "@/lib/types";
/* ============================================================================
@ -70,6 +72,148 @@ 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;
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");
const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin");
const adminPb = await getAdminPocketBase();
const [order, items, customersResult] = await Promise.all([
getOrder(orderId),
getOrderItems(orderId),
adminPb.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 adminPb
.collection("quote_attachments")
.getFullList({
filter: `quote = "${quote.id}"`,
})
: [];
if (cancelled) return;
const customer =
(order.expand?.customer as import("@/lib/types").Customer) ||
customersResult[0];
const company =
(order.expand?.company as import("@/lib/types").Company) || {
id: order.company,
created: "",
updated: "",
collectionId: "",
collectionName: "",
company: "(unknown)",
address: "",
vendor: "",
company_email: "",
company_tel: "",
};
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 +231,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 +357,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.
@ -316,9 +465,12 @@ export default function OrdersPage() {
isExpanded={expanded.has(order.id)}
showAll={showAllByOrder.has(order.id)}
activeTab={activeTab}
aiExpanded={false}
onToggleExpand={() => toggleExpand(order.id)}
onToggleShowAll={() => toggleShowAll(order.id)}
onToggleAi={() => {}}
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>
);
}
@ -362,18 +525,24 @@ function OrderCard({
isExpanded,
showAll,
activeTab,
aiExpanded,
onToggleExpand,
onToggleShowAll,
onToggleAi,
onOpen,
onOpenReview,
}: {
order: import("@/lib/types").Order;
items: OrderItem[];
isExpanded: boolean;
showAll: boolean;
activeTab: TabGroup;
aiExpanded: boolean;
onToggleExpand: () => void;
onToggleShowAll: () => void;
onToggleAi: () => void;
onOpen: () => void;
onOpenReview: () => void;
}) {
const complete = isOrderComplete(items);
// Tabs accent — thin colored rail on the left of the card so the
@ -568,7 +737,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"