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:
parent
f8784e3ba9
commit
de87eb312e
1 changed files with 188 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue