diff --git a/src/app/(app)/orders/sprint/page.tsx b/src/app/(app)/orders/sprint/page.tsx index a58f379..a255fa1 100644 --- a/src/app/(app)/orders/sprint/page.tsx +++ b/src/app/(app)/orders/sprint/page.tsx @@ -20,10 +20,13 @@ 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 { withAlpha } from "@/lib/item-status-ui"; import { cn } from "@/lib/utils"; import type { Category, Supplier } from "@/lib/types"; import { OrderSprintProcessModal } from "@/components/order-sprint-process-modal"; +const UNKNOWN_SLUG = "cat_unknown"; + export default function OrderSprintPage() { const [items, setItems] = useState([]); const [categories, setCategories] = useState([]); @@ -31,13 +34,22 @@ export default function OrderSprintPage() { 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__"; + + // Resolve the admin-managed "Unknown / Other" category by its + // canonical slug. Items with no category land here so they share a + // single tab with the records explicitly assigned to it (previously + // these were rendered as two separate "Unknown" and "Unknown / Other" + // tabs). + const unknownCategory = useMemo( + () => categories.find((c) => c.slug === UNKNOWN_SLUG), + [categories], + ); + const unknownKey = unknownCategory?.id ?? "__unknown__"; const fetchData = useCallback(async (silent = false) => { if (!silent) setLoading(true); @@ -45,27 +57,27 @@ export default function OrderSprintPage() { 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, '\\"')}"`; - }; + // Always fetch the full pending-no-PO set; client-side bucketing + // (see `buckets` below) drives tab switching. This avoids the + // full refetch on every tab click that the previous paginated + // version suffered from. + const filter = "item_status = 'pending' && purchase_order = ''"; + const expand = "order,order.customer,category"; - const [itemsRes, catsRes, countsData, suppliersRes] = await Promise.all([ - pb.collection("order_items").getList(page, SPRINT_PER_PAGE, { - filter: getCategoryFilter(), - expand: "order,order.customer,category", + const [itemsRes, catsRes, suppliersRes] = await Promise.all([ + pb.collection("order_items").getList(1, SPRINT_PER_PAGE, { + filter, + expand, sort: "created", fields: [ - "id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,ai_item_insights", + "id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,po_description,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 })), + }).catch(() => ({ items: [], totalPages: 1, totalItems: 0 })), 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 }), ]); @@ -73,34 +85,57 @@ export default function OrderSprintPage() { setTotalPages(itemsRes.totalPages); setCategories(catsRes as unknown as Category[]); setSuppliers(suppliersRes as unknown as Supplier[]); - setCategoryCounts(countsData.counts || {}); - setTotalUnprocessed(countsData.total ?? 0); + // The header counter uses the same per-page slice we display + // (the API caps at SPRINT_PER_PAGE). For a rough "still work to + // do" badge that's good enough — when it grows past the page + // cap the operator knows to dig into the tab anyway. + setTotalUnprocessed(itemsRes.totalItems); } 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]); + // Bucket items into per-category arrays. Blank-category items land + // in the Unknown / Other bucket so the count is honest (matches what + // the counts route reports). + const buckets = useMemo(() => { + const map = new Map(); + map.set("__all__", items); + map.set(unknownKey, []); + for (const cat of categories) map.set(cat.id, []); + for (const item of items) { + const catId = item.expand?.category?.id || item.category || ""; + if (catId && map.has(catId)) { + map.get(catId)!.push(item); + } else { + map.get(unknownKey)!.push(item); + } + } + return map; + }, [items, categories, unknownKey]); + 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 }); + out.push({ key: cat.id, label: cat.name, color: cat.color, count: buckets.get(cat.id)?.length ?? 0 }); } return out; - }, [categories, categoryCounts, totalUnprocessed]); + }, [categories, buckets, totalUnprocessed]); + + const visibleItems = useMemo(() => { + if (activeCategory === "__all__") return buckets.get("__all__") ?? []; + return buckets.get(activeCategory) ?? []; + }, [activeCategory, buckets]); return (
@@ -130,7 +165,7 @@ export default function OrderSprintPage() { { setActiveCategory(next); setPage(1); }} + onChange={setActiveCategory} ariaLabel="Filter order items by category" /> @@ -139,7 +174,7 @@ export default function OrderSprintPage() { Loading…
- ) : items.length === 0 ? ( + ) : visibleItems.length === 0 ? ( {totalUnprocessed === 0 ? "No items waiting for PO." : "Nothing in this tab."} @@ -152,20 +187,23 @@ export default function OrderSprintPage() { Order - + Description Qty Supplier - Category + {activeCategory === "__all__" && ( + Category + )} Action - {items.map((item) => ( + {visibleItems.map((item) => ( setModalItem(item)} /> ))} @@ -179,11 +217,12 @@ export default function OrderSprintPage() { each row becomes a stacked card so the action button stays a comfortable 44px hit-target on touch. */}
- {items.map((item) => ( + {visibleItems.map((item) => ( setModalItem(item)} /> ))} @@ -191,7 +230,7 @@ export default function OrderSprintPage() { )} - {!loading && totalPages > 1 && ( + {!loading && totalPages > 1 && activeCategory === "__all__" && (
Page {page} of {totalPages} @@ -222,6 +261,8 @@ type OrderSprintItem = { sku?: string; notes?: string; category?: string; + po_description?: string; + item_status?: string; ai_item_insights?: Record | null; expand?: { order?: { id: string; order_number: string; expand?: { customer?: { customer: string; expand?: { company?: { company: string } } } } }; @@ -229,7 +270,19 @@ type OrderSprintItem = { }; }; -function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; suppliers: Supplier[]; onProcess: () => void }) { +function OrderSprintRow({ + item, + suppliers, + showCategoryColumn, + onProcess, +}: { + item: OrderSprintItem; + suppliers: Supplier[]; + // When filtered to a single category the pill is redundant — every + // row shows the same one. Hide it instead of cluttering the table. + showCategoryColumn: boolean; + onProcess: () => void; +}) { const orderNumber = item.expand?.order?.order_number || "—"; const orderId = item.expand?.order?.id; const customer = item.expand?.order?.expand?.customer?.customer; @@ -238,9 +291,9 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; const categoryColor = item.expand?.category?.color || "#6b7280"; const supplierName = useMemo(() => { - if (!item.supplier) return "—"; + if (!item.supplier) return null; const supplier = suppliers.find((s) => s.id === item.supplier); - return supplier?.suppliername || "—"; + return supplier?.suppliername ?? null; }, [item.supplier, suppliers]); return ( @@ -259,7 +312,7 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; )}
- + @@ -269,16 +322,31 @@ function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; {item.sku &&
SKU: {item.sku}
} {item.quantity ?? "—"} - {supplierName} - {categoryName ? ( - - {categoryName} - + {supplierName ? ( + {supplierName} ) : ( - — + no supplier )} + {showCategoryColumn && ( + + {categoryName ? ( + + {categoryName} + + ) : ( + — + )} + + )}
- onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> + onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
- +
- onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" /> + onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
- +
- onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> + onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
@@ -356,6 +383,32 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc + {/* PO description — what the supplier sees on the PO. Defaults + to the order description so the buyer-tasks pending → + processed transition fires for the common no-op case. + Mirrors the canonical /orders/[id]/process gate. */} +
+
+ + {poDescription.trim() !== (item.description || "").trim() && ( + + )} +
+