diff --git a/src/app/(app)/orders/sprint/page.tsx b/src/app/(app)/orders/sprint/page.tsx new file mode 100644 index 0000000..a58f379 --- /dev/null +++ b/src/app/(app)/orders/sprint/page.tsx @@ -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([]); + const [categories, setCategories] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [activeCategory, setActiveCategory] = useState("__all__"); + const [categoryCounts, setCategoryCounts] = useState>({}); + const [totalUnprocessed, setTotalUnprocessed] = useState(0); + const SPRINT_PER_PAGE = 50; + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [modalItem, setModalItem] = useState(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 ( +
+
+
+

+ + Order Sprint +

+

+ {totalUnprocessed} item{totalUnprocessed === 1 ? "" : "s"} waiting for PO +

+
+
+ void fetchData(true)} /> + + + + + Refresh + +
+
+ + { setActiveCategory(next); setPage(1); }} + ariaLabel="Filter order items by category" + /> + + {loading ? ( +
+ + Loading… +
+ ) : items.length === 0 ? ( + + {totalUnprocessed === 0 ? "No items waiting for PO." : "Nothing in this tab."} + + ) : ( + <> + + +
+ + + + + + + + + + + + + + {items.map((item) => ( + setModalItem(item)} + /> + ))} + +
OrderDescriptionQtySupplierCategoryAction
+
+
+
+ + {/* Mobile cards — + {items.map((item) => ( + setModalItem(item)} + /> + ))} +
+ + )} + + {!loading && totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} + + setModalItem(null)} + onProcessed={() => { setModalItem(null); void fetchData(true); }} + /> + + ); +} + +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 | 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 ( + + +
+ {orderId ? ( + {orderNumber} + ) : ( + {orderNumber} + )} + {customer && ( +
+ {company ? `${company} · ` : ""}{customer} +
+ )} +
+ + + + + + + {item.description || (no description)} + + {item.sku &&
SKU: {item.sku}
} + + {item.quantity ?? "—"} + {supplierName} + + {categoryName ? ( + + {categoryName} + + ) : ( + — + )} + + + + + + ); +} + +/** + * Mobile ( 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 ( + + + {/* Row 1: order link + qty */} +
+ {orderId ? ( + + {orderNumber} + + ) : ( + {orderNumber} + )} + + · Qty {item.quantity ?? "—"} + + {categoryName && ( + + {categoryName} + + )} +
+ + {/* Customer / company — touch devices can't hover, so we + inline this rather than hiding it behind a tooltip. */} + {(customer || company) && ( +
+ {[company, customer].filter(Boolean).join(" · ")} +
+ )} + + {/* Description + AI history popover */} +
+ +

+ {item.description || ( + (no description) + )} +

+
+ + {/* SKU + Supplier */} +
+ {item.sku && ( + SKU: {item.sku} + )} + + {supplierName} + +
+ + {/* Action button — full-width touch target */} + +
+
+ ); +} diff --git a/src/app/api/v1/orders/sprint/counts/route.ts b/src/app/api/v1/orders/sprint/counts/route.ts new file mode 100644 index 0000000..779eef8 --- /dev/null +++ b/src/app/api/v1/orders/sprint/counts/route.ts @@ -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 = {}; + 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 }); + } +} \ No newline at end of file diff --git a/src/components/order-sprint-process-modal.tsx b/src/components/order-sprint-process-modal.tsx new file mode 100644 index 0000000..af8e65d --- /dev/null +++ b/src/components/order-sprint-process-modal.tsx @@ -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 | 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([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [availableSuppliers, setAvailableSuppliers] = useState([]); + 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(); + 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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> + + +
+ + + Process Order Item + +

{item.description || "(no description)"}

+
+ +
+ + + {/* AI Insights */} +
+ + {aiInsightsExpanded && ( +
{aiInsights ? : }
+ )} +
+ + {/* Order History */} +
+
+
+ + Recent Orders ({orderHistory.length}) +
+ {loadingHistory && } +
+ {orderHistory.length > 0 ? ( +
+ {orderHistory.slice(0, 5).map((h) => ( +
+
+
{h.description}
+
+ {h.orderNumber} + · + R{h.costPrice?.toFixed(2) || "—"} + · + R{h.sellingPrice?.toFixed(2) || "—"} + · + {h.markup}% +
+
+ +
+ ))} +
+ ) : !loadingHistory &&

No order history found.

} +
+ + {/* Available Suppliers */} +
+ + {suppliersExpanded && ( +
+ {loadingSuppliers ? ( +
Searching...
+ ) : availableSuppliers.length > 0 ? ( +
+ {availableSuppliers.map((s) => ( +
+ +
+
{s.name}
+ {s.lastCost &&
Last cost: R{s.lastCost.toFixed(2)}{s.brand && ` · ${s.brand}`}
} +
+ +
+ ))} +
+ ) :

No suppliers found in price list.

} +
+ )} +
+ + {/* Pricing */} +
+
+
+ + +
+ onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> +
+
+
+ + +
+ onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" /> +
+
+
+ + +
+ onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> +
+
+ + {/* Supplier */} +
+ + +
+ + {/* Notes */} +
+ +