diff --git a/app/page.tsx b/app/page.tsx index 5d4582f..130c4c4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,7 +6,7 @@ import { OrderItem } from '@/lib/types'; import { useAuth } from '@/components/AuthProvider'; import BottomNav from '@/components/BottomNav'; import CollectionItemCard from '@/components/CollectionItemCard'; -import SupplierGroup from '@/components/SupplierGroup'; +import SupplierGroup, { POSubGroup } from '@/components/SupplierGroup'; import AreaGroup from '@/components/AreaGroup'; import CollectModal from '@/components/CollectModal'; import ProblemModal from '@/components/ProblemModal'; @@ -21,6 +21,8 @@ import { isAuthenticated, logout, getItemDisplayQty, + bulkCollect, + bulkProblem, } from '@/lib/pocketbase'; import { getCached, setCached } from '@/lib/itemsCache'; import { cn, clusterByProximity } from '@/lib/utils'; @@ -34,6 +36,9 @@ import { X, Package, Layers, + FileText, + CheckCheck, + AlertTriangle, } from 'lucide-react'; // Cursor-harmonized supplier color palette: same warm cream surface @@ -80,12 +85,15 @@ export default function CollectionsPage() { const [showProblemModal, setShowProblemModal] = useState(false); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null); const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact'); - const [groupMode, setGroupMode] = useState<'supplier' | 'area'>('supplier'); + const [groupMode, setGroupMode] = useState<'supplier' | 'po' | 'area'>('supplier'); const [statusFilter, setStatusFilter] = useState<'assigned' | 'collection_pool' | 'problem'>('assigned'); // Track which supplier groups are expanded const [expandedSuppliers, setExpandedSuppliers] = useState>(new Set()); const [expandedAreas, setExpandedAreas] = useState>(new Set()); + // Per-PO expand state for 'po' groupMode — PO sub-headers can be + // collapsed independently of the supplier they're inside. + const [expandedPOs, setExpandedPOs] = useState>(new Set()); useEffect(() => { if (!authLoading && !isLoggedIn) router.push('/login/'); @@ -128,6 +136,14 @@ const loadItems = useCallback(async (showLoading = true) => { const clusters = clusterByProximity(data, 2); clusters.forEach(c => allAreaIds.add(c.id)); setExpandedAreas(allAreaIds); + // Auto-expand every PO sub-group on first load — drivers want to + // see all of a PO's items at once. Per-PO collapse is opt-in. + const allPoIds = new Set(); + data.forEach(i => { + const po = i.expand?.purchase_order; + allPoIds.add(po?.id || '__no_po__'); + }); + setExpandedPOs(allPoIds); } catch (err) { console.error('Failed to load collections:', err); setError('Failed to load collections. Pull down to retry.'); @@ -273,6 +289,53 @@ const loadItems = useCallback(async (showLoading = true) => { } }; + // Bulk actions — used by the per-PO "Mark all" buttons in the Invoice + // group mode. Skips the per-item modal flow and stamps every row in + // the list straight to its terminal status. The driver is presumed + // to have visually confirmed the items under the PO sub-header. + // + // On success the affected rows are removed from local state so the + // Assigned-tab list shrinks immediately without waiting for the next + // refetch — drivers want instant feedback that the action landed. + const handleBulkCollect = async (poLabel: string, itemsToCollect: OrderItem[]) => { + if (itemsToCollect.length === 0) return; + try { + const result = await bulkCollect(itemsToCollect); + const ids = new Set(itemsToCollect.map((i) => i.id)); + setItems((prev) => prev.filter((i) => !ids.has(i.id))); + if (result.failed === 0) { + showToast(`Collected ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'success'); + } else { + showToast( + `Collected ${result.ok}, ${result.failed} failed · ${poLabel}`, + 'warning' + ); + } + } catch (err) { + showToast(`Bulk collect failed · ${poLabel}`, 'error'); + } + }; + + const handleBulkProblem = async (poLabel: string, itemsToFlag: OrderItem[]) => { + if (itemsToFlag.length === 0) return; + const description = `Bulk-flagged from ${poLabel}`; + try { + const result = await bulkProblem(itemsToFlag, description); + const ids = new Set(itemsToFlag.map((i) => i.id)); + setItems((prev) => prev.filter((i) => !ids.has(i.id))); + if (result.failed === 0) { + showToast(`Flagged ${result.ok} item${result.ok === 1 ? '' : 's'} · ${poLabel}`, 'warning'); + } else { + showToast( + `Flagged ${result.ok}, ${result.failed} failed · ${poLabel}`, + 'error' + ); + } + } catch (err) { + showToast(`Bulk flag failed · ${poLabel}`, 'error'); + } + }; + const handleNavigate = (lat: number, lng: number, name: string) => { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const url = isIOS @@ -304,8 +367,17 @@ const loadItems = useCallback(async (showLoading = true) => { }); }; + const togglePO = (poId: string) => { + setExpandedPOs(prev => { + const next = new Set(prev); + if (next.has(poId)) next.delete(poId); + else next.add(poId); + return next; + }); + }; + const expandAll = () => { - if (groupMode === 'supplier') { + if (groupMode === 'supplier' || groupMode === 'po') { const allIds = new Set(); filteredItems.forEach(i => { if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id); @@ -316,14 +388,27 @@ const loadItems = useCallback(async (showLoading = true) => { areaClusters.forEach(c => allIds.add(c.id)); setExpandedAreas(allIds); } + // For PO mode, expandAll also opens every PO sub-header so the + // driver sees the full breakdown without an extra tap. + if (groupMode === 'po') { + const allPoIds = new Set(); + filteredItems.forEach(i => { + const po = i.expand?.purchase_order; + allPoIds.add(po?.id || '__no_po__'); + }); + setExpandedPOs(allPoIds); + } }; const collapseAll = () => { - if (groupMode === 'supplier') { + if (groupMode === 'supplier' || groupMode === 'po') { setExpandedSuppliers(new Set()); } else { setExpandedAreas(new Set()); } + if (groupMode === 'po') { + setExpandedPOs(new Set()); + } }; // Group by supplier - items without supplier are grouped under "Unknown" @@ -341,6 +426,51 @@ const loadItems = useCallback(async (showLoading = true) => { ); }, [filteredItems]); + // Group by supplier, then by PO within each supplier. Used by the + // 'po' groupMode so the driver can see (and bulk-collect) one PO at + // a time even when a single supplier has multiple invoices. + // + // Shape: + // [{ supplier, pos: [{ poId, poNumber, items: [...] }, ...] }, ...] + // + // Items without a PO are bucketed under '__no_po__' so they still + // surface — better to render "No PO" than to silently drop them. + const groupedByPO = useMemo(() => { + const supplierBuckets: Record['supplier'] | null; + pos: Record; + }> = {}; + + filteredItems.forEach((item) => { + const supplier = item.expand?.supplier ?? null; + const supplierKey = supplier?.id || '__unknown__'; + if (!supplierBuckets[supplierKey]) { + supplierBuckets[supplierKey] = { supplier, pos: {} }; + } + const po = item.expand?.purchase_order; + const poKey = po?.id || '__no_po__'; + if (!supplierBuckets[supplierKey].pos[poKey]) { + supplierBuckets[supplierKey].pos[poKey] = { + poId: poKey, + poNumber: po?.po_number || 'No PO', + items: [], + }; + } + supplierBuckets[supplierKey].pos[poKey].items.push(item); + }); + + return Object.values(supplierBuckets) + .sort((a, b) => + (a.supplier?.suppliername || 'zzz').localeCompare(b.supplier?.suppliername || 'zzz') + ) + .map((bucket) => ({ + supplier: bucket.supplier, + pos: Object.values(bucket.pos).sort((a, b) => + a.poNumber.localeCompare(b.poNumber, undefined, { numeric: true }) + ), + })); + }, [filteredItems]); + // Group by geographic proximity — items within 2km share a cluster color const areaClusters = useMemo(() => { return clusterByProximity(filteredItems, 2); @@ -460,6 +590,18 @@ const loadItems = useCallback(async (showLoading = true) => {