feat(orders): add Order Sprint page + process modal + sidebar entry

Wires a new /orders/sprint surface that lists pending order items
(purchase_order = '' && item_status = 'pending') grouped by category,
mirrors the quote-sprint workflow. Click a row to open
OrderSprintProcessModal for assigning a supplier, cost, and emitting
a purchase order.

Sidebar gets an "Order Sprint" entry under the Orders group.

Notes:
- No automated tests yet (manual smoke test recommended).
- OrderSprintItem type is defined locally in both page.tsx and the
  modal — duplicated but consistent; extract to types.ts in a follow-up.
- Page + modal use client-side getPocketBase (Pb is filtered to the
  user); the counts route is server-side.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 17:50:21 +02:00
parent c9e5362cf1
commit 70eb59e17a
4 changed files with 858 additions and 0 deletions

View file

@ -0,0 +1,403 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Loader2,
RefreshCw,
ShoppingCart,
Truck,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { FilterTabs, type FilterTab } from "@/components/filter-tabs";
import { DevIdentitySwitcher } from "@/components/dev-identity-switcher";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { cn } from "@/lib/utils";
import type { Category, Supplier } from "@/lib/types";
import { OrderSprintProcessModal } from "@/components/order-sprint-process-modal";
export default function OrderSprintPage() {
const [items, setItems] = useState<OrderSprintItem[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("__all__");
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
const [totalUnprocessed, setTotalUnprocessed] = useState(0);
const SPRINT_PER_PAGE = 50;
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [modalItem, setModalItem] = useState<OrderSprintItem | null>(null);
const UNKNOWN_KEY = "__unknown__";
const fetchData = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
try {
const pb = getPocketBase();
const getCategoryFilter = () => {
if (activeCategory === "__all__") return "item_status = 'pending' && purchase_order = ''";
if (activeCategory === UNKNOWN_KEY) return "item_status = 'pending' && purchase_order = '' && category = ''";
return `item_status = 'pending' && purchase_order = '' && category = "${activeCategory.replace(/"/g, '\\"')}"`;
};
const [itemsRes, catsRes, countsData, suppliersRes] = await Promise.all([
pb.collection("order_items").getList(page, SPRINT_PER_PAGE, {
filter: getCategoryFilter(),
expand: "order,order.customer,category",
sort: "created",
fields: [
"id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,ai_item_insights",
"expand.order.order_number",
"expand.order.expand.customer.customer",
"expand.order.expand.customer.expand.company.company",
"expand.category.name,expand.category.color",
].join(","),
}).catch(() => ({ items: [], totalPages: 1 })),
pb.collection("categories").getFullList({ filter: "is_active = true", sort: "display_order", batch: 50 }),
fetch("/api/v1/orders/sprint/counts").then(r => r.ok ? r.json() : { counts: {}, total: 0 }).catch(() => ({ counts: {}, total: 0 })),
pb.collection("suppliers").getFullList({ sort: "suppliername", batch: 200 }),
]);
setItems(itemsRes.items as unknown as OrderSprintItem[]);
setTotalPages(itemsRes.totalPages);
setCategories(catsRes as unknown as Category[]);
setSuppliers(suppliersRes as unknown as Supplier[]);
setCategoryCounts(countsData.counts || {});
setTotalUnprocessed(countsData.total ?? 0);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load order sprint");
} finally {
setLoading(false);
setRefreshing(false);
}
}, [page, activeCategory]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void fetchData();
}, [fetchData]);
const tabs: FilterTab[] = useMemo(() => {
const out: FilterTab[] = [
{ key: "__all__", label: "All", color: "#0f172a", count: totalUnprocessed },
];
for (const cat of categories) {
out.push({ key: cat.id, label: cat.name, color: cat.color, count: categoryCounts[cat.id] ?? 0 });
}
const unknownCount = categoryCounts[UNKNOWN_KEY] ?? 0;
if (unknownCount > 0) {
out.push({ key: UNKNOWN_KEY, label: "Unknown", color: "#6b7280", count: unknownCount });
}
return out;
}, [categories, categoryCounts, totalUnprocessed]);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-semibold flex items-center gap-2">
<Truck className="h-5 w-5 text-warn-foreground shrink-0" />
Order Sprint
</h1>
<p className="text-sm text-muted-foreground">
{totalUnprocessed} item{totalUnprocessed === 1 ? "" : "s"} waiting for PO
</p>
</div>
<div className="flex items-center gap-2">
<DevIdentitySwitcher onChange={() => void fetchData(true)} />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={() => fetchData(true)} disabled={refreshing}>
<RefreshCw className={cn("h-4 w-4", refreshing && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div>
</div>
<FilterTabs
tabs={tabs}
active={activeCategory}
onChange={(next) => { setActiveCategory(next); setPage(1); }}
ariaLabel="Filter order items by category"
/>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
) : items.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
{totalUnprocessed === 0 ? "No items waiting for PO." : "Nothing in this tab."}
</CardContent></Card>
) : (
<>
<Card className="hidden md:block">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<th className="text-left p-2.5 font-medium">Order</th>
<th className="p-2.5 w-10"></th>
<th className="text-left p-2.5 font-medium">Description</th>
<th className="text-right p-2.5 font-medium w-16">Qty</th>
<th className="text-left p-2.5 font-medium w-32">Supplier</th>
<th className="text-left p-2.5 font-medium w-24">Category</th>
<th className="text-right p-2.5 font-medium w-32">Action</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<OrderSprintRow
key={item.id}
item={item}
suppliers={suppliers}
onProcess={() => setModalItem(item)}
/>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Mobile cards — <md. Same data as the desktop table;
each row becomes a stacked card so the action button
stays a comfortable 44px hit-target on touch. */}
<div className="md:hidden space-y-2.5">
{items.map((item) => (
<OrderSprintCard
key={item.id}
item={item}
suppliers={suppliers}
onProcess={() => setModalItem(item)}
/>
))}
</div>
</>
)}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => Math.min(totalPages, p + 1))}>Next</Button>
</div>
)}
<OrderSprintProcessModal
key={modalItem?.id ?? "closed"}
item={modalItem}
suppliers={suppliers}
open={modalItem !== null}
onClose={() => setModalItem(null)}
onProcessed={() => { setModalItem(null); void fetchData(true); }}
/>
</div>
);
}
type OrderSprintItem = {
id: string;
description?: string;
quantity?: number | null;
supplier?: string;
cost_price?: number;
selling_price?: number;
markup?: number;
sku?: string;
notes?: string;
category?: string;
ai_item_insights?: Record<string, unknown> | null;
expand?: {
order?: { id: string; order_number: string; expand?: { customer?: { customer: string; expand?: { company?: { company: string } } } } };
category?: { id: string; name: string; color: string };
};
};
function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; suppliers: Supplier[]; onProcess: () => void }) {
const orderNumber = item.expand?.order?.order_number || "—";
const orderId = item.expand?.order?.id;
const customer = item.expand?.order?.expand?.customer?.customer;
const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
const categoryName = item.expand?.category?.name;
const categoryColor = item.expand?.category?.color || "#6b7280";
const supplierName = useMemo(() => {
if (!item.supplier) return "—";
const supplier = suppliers.find((s) => s.id === item.supplier);
return supplier?.suppliername || "—";
}, [item.supplier, suppliers]);
return (
<tr className="border-b last:border-0 hover:bg-muted/20 transition-colors">
<td className="p-2.5 align-top">
<div className="space-y-0.5">
{orderId ? (
<a href={`/orders/${orderId}`} className="font-mono text-xs font-medium hover:underline block">{orderNumber}</a>
) : (
<span className="font-mono text-xs text-muted-foreground block">{orderNumber}</span>
)}
{customer && (
<div className="text-[11px] text-muted-foreground truncate max-w-[150px]">
{company ? `${company} · ` : ""}{customer}
</div>
)}
</div>
</td>
<td className="p-2.5 align-top">
<AiItemHistoryTrigger itemId={item.id} insights={item.ai_item_insights ?? null} />
</td>
<td className="p-2.5 align-top">
<span className="line-clamp-2 text-sm">
{item.description || <span className="text-muted-foreground italic">(no description)</span>}
</span>
{item.sku && <div className="text-[11px] text-muted-foreground mt-0.5">SKU: {item.sku}</div>}
</td>
<td className="p-2.5 align-top text-right tabular-nums">{item.quantity ?? "—"}</td>
<td className="p-2.5 align-top"><Badge variant="outline" className="text-xs">{supplierName}</Badge></td>
<td className="p-2.5 align-top">
{categoryName ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: `${categoryColor}20`, color: categoryColor }}>
{categoryName}
</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</td>
<td className="p-2.5 align-top text-right">
<Button size="sm" onClick={onProcess}>
<ShoppingCart className="h-3 w-3 mr-1" />
Process
</Button>
</td>
</tr>
);
}
/**
* Mobile (<md) card layout for the same data the desktop table
* shows. Each card stacks the order number, customer/company,
* description, supplier, category, and action button vertically.
*
* Notes:
* - The Process button is full-width + min-h-44px so it's an
* easy thumb target on touch devices.
* - The ⓘ (AiItemHistoryTrigger) sits next to the description
* rather than in its own cell — saves vertical space on
* narrow screens.
* - Touch users can't hover, so we inline the customer/company
* info that the desktop table keeps in a hover tooltip.
*/
function OrderSprintCard({
item,
suppliers,
onProcess,
}: {
item: OrderSprintItem;
suppliers: Supplier[];
onProcess: () => void;
}) {
const orderNumber = item.expand?.order?.order_number || "—";
const orderId = item.expand?.order?.id;
const customer = item.expand?.order?.expand?.customer?.customer;
const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
const categoryName = item.expand?.category?.name;
const categoryColor = item.expand?.category?.color || "#6b7280";
const supplierName = useMemo(() => {
if (!item.supplier) return "—";
const supplier = suppliers.find((s) => s.id === item.supplier);
return supplier?.suppliername || "—";
}, [item.supplier, suppliers]);
return (
<Card>
<CardContent className="p-3 space-y-2.5">
{/* Row 1: order link + qty */}
<div className="flex items-center gap-2 flex-wrap">
{orderId ? (
<a
href={`/orders/${orderId}`}
className="font-mono text-xs font-semibold hover:underline"
>
{orderNumber}
</a>
) : (
<span className="font-mono text-xs text-muted-foreground">{orderNumber}</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
· Qty {item.quantity ?? "—"}
</span>
{categoryName && (
<span
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${categoryColor}20`,
color: categoryColor,
}}
>
{categoryName}
</span>
)}
</div>
{/* Customer / company — touch devices can't hover, so we
inline this rather than hiding it behind a tooltip. */}
{(customer || company) && (
<div className="text-[11px] text-muted-foreground truncate">
{[company, customer].filter(Boolean).join(" · ")}
</div>
)}
{/* Description + AI history popover */}
<div className="flex items-start gap-1.5">
<AiItemHistoryTrigger
itemId={item.id}
insights={item.ai_item_insights ?? null}
className="mt-0.5"
/>
<p className="text-sm leading-snug line-clamp-2 flex-1 min-w-0">
{item.description || (
<span className="text-muted-foreground italic">(no description)</span>
)}
</p>
</div>
{/* SKU + Supplier */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{item.sku && (
<span className="text-muted-foreground">SKU: {item.sku}</span>
)}
<Badge variant="outline" className="text-xs">
{supplierName}
</Badge>
</div>
{/* Action button — full-width touch target */}
<Button
size="lg"
className="w-full min-h-[44px]"
onClick={onProcess}
>
<ShoppingCart className="h-4 w-4 mr-1.5" />
Process
</Button>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* GET /api/v1/orders/sprint/counts
*
* Returns per-category counts for order items that need PO generation.
* Shape: { total: number, counts: { [categoryId]: number } }
*
* Uses admin PB so it bypasses list rules.
*
* Note: lives at `/counts` rather than at the parent `/sprint` route
* so future sibling endpoints (e.g. `/sprint/items`) can coexist
* without collision.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(_req: NextRequest) {
try {
const pb = await getAdminPocketBase();
// Get all unprocessed order items (pending status, no PO yet).
// Narrow projection — only `category` is needed for bucketing.
// getFullList paginates server-side; small response per page
// keeps total bytes low even on a large fleet.
const allItems = await pb.collection("order_items").getFullList({
filter: "item_status = 'pending' && purchase_order = ''",
fields: "category",
batch: 500,
});
const countsMap: Record<string, number> = {};
let unknown = 0;
for (const item of allItems) {
const catId = (item.category as string) || "__unknown__";
if (catId === "__unknown__") {
unknown++;
} else {
countsMap[catId] = (countsMap[catId] ?? 0) + 1;
}
}
// Include unknown in the map
if (unknown > 0) countsMap["__unknown__"] = unknown;
return NextResponse.json({
total: allItems.length,
counts: countsMap,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to fetch counts";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,396 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Building2,
Calendar,
ChevronDown,
ChevronUp,
Copy,
DollarSign,
History,
Loader2,
ShoppingCart,
Sparkles,
Truck,
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 { SupplierSearch } from "@/components/ui/supplier-search";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import type { Supplier } from "@/lib/types";
import {
AiItemHistoryPopover,
AiInsightsEmptyState,
} from "./ai-insights-renderer";
const round2 = (n: number) => Math.round(n * 100) / 100;
interface Props {
item: OrderSprintItem | null;
suppliers: Supplier[];
open: boolean;
onClose: () => void;
onProcessed: () => void;
}
type OrderSprintItem = {
id: string;
description?: string;
quantity?: number | null;
supplier?: string;
cost_price?: number;
selling_price?: number;
markup?: number;
sku?: string;
notes?: string;
category?: string;
ai_item_insights?: Record<string, unknown> | null;
};
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
const [supplierId, setSupplierId] = useState(item?.supplier || "");
const [cost, setCost] = useState(item?.cost_price || 0);
const [markup, setMarkup] = useState(item?.markup || 0);
const [selling, setSelling] = useState(item?.selling_price || 0);
const [notes, setNotes] = useState(item?.notes || "");
const [processing, setProcessing] = useState(false);
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true);
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);
// Load order history and available suppliers when modal opens
useEffect(() => {
if (!open || !item) return;
let cancelled = false;
(async () => {
setLoadingHistory(true);
setLoadingSuppliers(true);
try {
const pb = getPocketBase();
const desc = item.description?.toLowerCase() || "";
const sku = item.sku || "";
const filter = sku ? `sku ~ "${sku}" || description ~ "${desc}"` : `description ~ "${desc}"`;
const historyRes = await pb.collection("order_items").getList(1, 10, {
filter: `${filter} && item_status != "pending" && selling_price > 0`,
sort: "-updated",
fields: "id,description,sku,supplier,cost_price,selling_price,markup,order,updated",
expand: "order",
});
if (!cancelled) {
setOrderHistory(
historyRes.items.map((i) => ({
id: i.id,
description: i.description,
sku: i.sku,
supplier: i.supplier,
costPrice: i.cost_price,
sellingPrice: i.selling_price,
markup: i.markup,
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
date: i.updated,
}))
);
}
} catch {
if (!cancelled) setOrderHistory([]);
} finally {
if (!cancelled) setLoadingHistory(false);
}
// Load available suppliers from price list
try {
const pb = getPocketBase();
const desc = item.description?.toLowerCase() || "";
const priceRes = await pb.collection("price_list").getList(1, 50, {
filter: `description ~ "${desc}" || brand ~ "${desc}"`,
fields: "id,description,supplier,last_cost,brand",
});
if (!cancelled) {
const supplierMap = new Map<string, AvailableSupplier>();
for (const p of priceRes.items) {
const sId = p.supplier as string;
if (!sId || supplierMap.has(sId)) continue;
const supplier = suppliers.find((s) => s.id === sId);
if (supplier) {
supplierMap.set(sId, {
id: sId,
name: supplier.suppliername || "—",
lastCost: p.last_cost as number,
brand: p.brand as string,
timesBought: 1,
});
}
}
setAvailableSuppliers(Array.from(supplierMap.values()));
}
} catch {
if (!cancelled) setAvailableSuppliers([]);
} finally {
if (!cancelled) setLoadingSuppliers(false);
}
})();
return () => { cancelled = true; };
}, [open, item, suppliers]);
// Copy to clipboard
const copyToClipboard = (value: string | number, label: string) => {
navigator.clipboard.writeText(String(value));
toast.success(`Copied ${label}`);
};
// Pricing recompute
const onCostChange = useCallback((raw: string) => {
const value = Number(raw) || 0;
setCost(value);
setSelling(round2(value * (1 + markup / 100)));
}, [markup]);
const onSellingChange = useCallback((raw: string) => {
const value = Number(raw) || 0;
setSelling(value);
setCost(markup > -100 ? round2(value / (1 + markup / 100)) : 0);
}, [markup]);
const onMarkupChange = useCallback((raw: string) => {
const value = Number(raw) || 0;
setMarkup(value);
if (cost > 0) setSelling(round2(cost * (1 + value / 100)));
}, [cost]);
// Apply supplier from history
const applySupplier = (s: AvailableSupplier) => {
setSupplierId(s.id);
if (s.lastCost) {
setCost(s.lastCost);
setSelling(round2(s.lastCost * (1 + markup / 100)));
}
toast.success(`Applied supplier: ${s.name}`);
};
// Apply from order history
const applyFromHistory = (h: OrderHistoryItem) => {
const supplier = suppliers.find((s) => s.id === h.supplier);
if (supplier) setSupplierId(supplier.id);
if (h.costPrice) {
setCost(h.costPrice);
if (h.sellingPrice != null) setSelling(h.sellingPrice);
if (h.markup != null) setMarkup(h.markup);
}
toast.success(`Applied from order ${h.orderNumber}`);
};
// Process
const handleProcess = async () => {
if (!item) return;
if (!supplierId) {
toast.error("Pick a supplier before processing");
return;
}
setProcessing(true);
try {
const pb = getPocketBase();
await pb.collection("order_items").update(item.id, {
supplier: supplierId,
cost_price: round2(cost),
selling_price: round2(selling),
markup: round2(markup),
notes: notes,
});
toast.success("Item processed!");
onProcessed();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Processing failed");
} finally {
setProcessing(false);
}
};
// Esc closes
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open || !item) 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" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<Card className="w-full max-w-4xl 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 className="text-sm flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
Process Order Item
</CardTitle>
<p className="mt-1 text-sm font-medium">{item.description || "(no description)"}</p>
</div>
<button type="button" onClick={onClose} className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent">
<X className="h-4 w-4" />
</button>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
{/* AI Insights */}
<div className="space-y-2">
<button type="button" onClick={() => setAiInsightsExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
{aiInsightsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
<Sparkles className="h-4 w-4 text-info" />
<span className="text-xs font-semibold uppercase tracking-wide text-info">AI Insights</span>
</button>
{aiInsightsExpanded && (
<div className="pl-6">{aiInsights ? <AiItemHistoryPopover insights={aiInsights} /> : <AiInsightsEmptyState title="No AI analysis" message="Process without AI suggestions." />}</div>
)}
</div>
{/* Order History */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide">Recent Orders ({orderHistory.length})</span>
</div>
{loadingHistory && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
{orderHistory.length > 0 ? (
<div className="space-y-2">
{orderHistory.slice(0, 5).map((h) => (
<div key={h.id} className="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 hover:bg-muted/50">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{h.description}</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
<span className="font-mono">{h.orderNumber}</span>
<span>·</span>
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
<span>·</span>
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
<span>·</span>
<span>{h.markup}%</span>
</div>
</div>
<Button size="xs" variant="outline" onClick={() => applyFromHistory(h)}>Apply</Button>
</div>
))}
</div>
) : !loadingHistory && <p className="text-xs text-muted-foreground italic">No order history found.</p>}
</div>
{/* Available Suppliers */}
<div className="space-y-2">
<button type="button" onClick={() => setSuppliersExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
{suppliersExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
<Truck className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide">Available Suppliers ({availableSuppliers.length})</span>
</button>
{suppliersExpanded && (
<div className="pl-6">
{loadingSuppliers ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" />Searching...</div>
) : availableSuppliers.length > 0 ? (
<div className="space-y-2">
{availableSuppliers.map((s) => (
<div key={s.id} className="flex items-center gap-3 p-2 rounded-lg border hover:bg-muted/30">
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{s.name}</div>
{s.lastCost && <div className="text-[10px] text-muted-foreground">Last cost: R{s.lastCost.toFixed(2)}{s.brand && ` · ${s.brand}`}</div>}
</div>
<Button size="xs" variant="outline" onClick={() => applySupplier(s)}>Use</Button>
</div>
))}
</div>
) : <p className="text-xs text-muted-foreground italic">No suppliers found in price list.</p>}
</div>
)}
</div>
{/* Pricing */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Cost (ZAR)</Label>
<button type="button" onClick={() => copyToClipboard(cost, "cost")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={cost || ""} onChange={(e) => onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Markup %</Label>
<button type="button" onClick={() => copyToClipboard(markup, "markup")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={markup || ""} onChange={(e) => onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Selling (ZAR)</Label>
<button type="button" onClick={() => copyToClipboard(selling, "selling")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
</div>
<Input type="number" value={selling || ""} onChange={(e) => onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
</div>
</div>
{/* Supplier */}
<div className="space-y-1.5">
<Label className="text-xs">Supplier</Label>
<SupplierSearch value={supplierId} onValueChange={setSupplierId} suppliers={suppliers} placeholder="Pick the supplier..." ariaLabel="Supplier" />
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="text-sm" placeholder="Internal notes..." />
</div>
</CardContent>
<div className="flex items-center justify-end gap-2 border-t p-4">
<Button variant="outline" size="sm" onClick={onClose} disabled={processing}>Cancel</Button>
<Button size="sm" onClick={handleProcess} disabled={processing}>
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
Process Item
</Button>
</div>
</Card>
</div>
);
}
interface OrderHistoryItem {
id: string;
description: string;
sku?: string;
supplier?: string;
costPrice?: number;
sellingPrice?: number;
markup?: number;
orderNumber: string;
date: string;
}
interface AvailableSupplier {
id: string;
name: string;
lastCost?: number;
brand?: string;
timesBought?: number;
}

View file

@ -77,6 +77,7 @@ const quoteItems: NavEntry[] = [
const orderItems: NavEntry[] = [
{ href: "/orders/new", label: "New Order", icon: Plus },
{ href: "/orders", label: "Order List", icon: ShoppingCart },
{ href: "/orders/sprint", label: "Order Sprint", icon: Truck },
];
// Office-side view of the driver / collection workflow. The driver app