WFDASHDRIVER-APP/app/page.tsx
Dave 85a40a76bf fix(collections): keep stop expanded while working through its items
When a driver collects or reports a problem on an item, the supplier
group they were working through used to collapse — forcing them to
re-expand it for every single item in the stop.

Root cause was a useCallback dep cycle:

- loadItems had items.length in its dep array
- handleCollect / handleProblem filter the processed item out of state
- items.length decreasing rebuilt loadItems (new identity)
- the consuming useEffect([loadItems]) re-fired loadItems
- loadItems's setExpandedSuppliers(new Set()) wiped every group

Fix: mirror items.length into a useRef so loadItems can read the
current count for the loading-skeleton decision without subscribing
to it. loadItems now only rebuilds when statusFilter changes (mount,
filter chip tap, refresh), so processing an item no longer resets
the expansion state. Drivers can work through every item in a stop
without re-expanding.

Behavior preserved on real refreshes / tab switches / filter changes
— those still reset to collapsed, which matches the new 'flat list
of stops on open' UX.
2026-09-08 22:15:31 +02:00

914 lines
No EOL
39 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { OrderItem } from '@/lib/types';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import CollectionItemCard from '@/components/CollectionItemCard';
import SupplierGroup, { POSubGroup } from '@/components/SupplierGroup';
import AreaGroup from '@/components/AreaGroup';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import ItemDetailModal from '@/components/ItemDetailModal';
import {
getMyAssignedItems,
getMyAssignedStockItems,
getPoolItems,
getProblemItems,
updateCollectionStatus,
updateStockCollectionStatus,
uploadOrderItemAttachment,
reportProblem,
reportStockProblem,
isAuthenticated,
logout,
getItemDisplayQty,
bulkCollect,
bulkProblem,
bulkCollectStock,
bulkProblemStock,
} from '@/lib/pocketbase';
import { getCached, setCached } from '@/lib/itemsCache';
import { cn, clusterByProximity } from '@/lib/utils';
import {
RefreshCw,
LogOut,
MapPin,
AlertCircle,
CheckCircle2,
Search,
X,
Package,
Layers,
FileText,
CheckCheck,
AlertTriangle,
} from 'lucide-react';
// Cursor-harmonized supplier color palette: same warm cream surface
// for every group, with the *name* color living in a single muted
// tile (not a saturated left-border stripe). Each group gets a
// distinct hue but all read as part of the same family.
const SUPPLIER_COLORS = [
{ dot: 'bg-[#f54e00]', border: 'border-l-[#f54e00]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#f54e00]/30' }, // orange (signature)
{ dot: 'bg-[#18714f]', border: 'border-l-[#18714f]', light: 'bg-[#e6f1ec]', text: 'text-[#125a3e]', ring: 'ring-[#18714f]/30' }, // emerald
{ dot: 'bg-[#6c5a99]', border: 'border-l-[#6c5a99]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // violet
{ dot: 'bg-[#cf2d56]', border: 'border-l-[#cf2d56]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // rose
{ dot: 'bg-[#8a5a02]', border: 'border-l-[#8a5a02]', light: 'bg-[#fcf3d8]', text: 'text-[#8a5a02]', ring: 'ring-[#d49103]/30' }, // amber
{ dot: 'bg-[#26251e]', border: 'border-l-[#26251e]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#26251e]/30' }, // ink
{ dot: 'bg-[#6b6760]', border: 'border-l-[#6b6760]', light: 'bg-[#ebeae5]', text: 'text-[#4a4742]', ring: 'ring-[#6b6760]/30' }, // warm gray
{ dot: 'bg-[#0d9488]', border: 'border-l-[#0d9488]', light: 'bg-[#e6f5f3]', text: 'text-[#0a7a70]', ring: 'ring-[#0d9488]/30' }, // teal
{ dot: 'bg-[#8a1b3a]', border: 'border-l-[#8a1b3a]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // crimson
{ dot: 'bg-[#4a3d6b]', border: 'border-l-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
];
export default function CollectionsPage() {
const router = useRouter();
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
// Hydrate initial state from the in-memory cache so tab switching
// shows last-known data instantly (no loading skeleton) while the
// background re-fetch lands. Without this, every bottom-nav click
// shows a blank-then-skeleton-then-content flash, which is the
// "feels sluggish on PC" symptom drivers reported.
const [items, setItems] = useState<OrderItem[]>(() => {
if (typeof window === 'undefined') return [];
return getCached('assigned') ?? [];
});
const [filteredItems, setFilteredItems] = useState<OrderItem[]>(items);
// Only show the loading skeleton on first mount when we have
// absolutely nothing to show. Subsequent loads (returning to
// the tab, switching tabs) get the cached items immediately
// and the re-fetch happens silently in the background.
const [isLoading, setIsLoading] = useState<boolean>(items.length === 0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
const [showCollectModal, setShowCollectModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
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' | 'po' | 'area'>('supplier');
const [statusFilter, setStatusFilter] = useState<'assigned' | 'collection_pool' | 'problem'>('assigned');
// Track which supplier groups are expanded
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(new Set());
const [expandedAreas, setExpandedAreas] = useState<Set<string>>(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<Set<string>>(new Set());
// Mirror items.length into a ref so loadItems can decide whether to
// flash the loading skeleton WITHOUT making items.length a callback
// dependency. If items.length WERE a dep, every successful collect
// (which filters the collected item out of state) would rebuild
// loadItems, the auto-fire useEffect below would re-run loadItems,
// and the setExpandedSuppliers(new Set()) call inside it would
// collapse the supplier the driver is currently working through —
// forcing them to re-expand for every single item. The ref lets us
// keep the "don't flash a skeleton when items are already on screen"
// behavior while breaking the collapse loop.
const itemsLengthRef = useRef(0);
useEffect(() => {
itemsLengthRef.current = items.length;
}, [items.length]);
useEffect(() => {
if (!authLoading && !isLoggedIn) router.push('/login/');
}, [isLoggedIn, authLoading, router]);
// Each tab maps to a different PocketBase fetch — pool and problems
// are SYSTEM-WIDE (any driver can see them), assigned is the current
// driver's personal list only. This is the contract the brief asked
// for: "pool must show all items in collection_pool, same with
// problems; default when you open the app should be assigned".
//
// The cache write on success is what makes subsequent tab switches
// instant — the next page mount reads the cache and renders the
// last-known list with no skeleton flash.
const loadItems = useCallback(async (showLoading = true) => {
if (!isAuthenticated()) return;
// Only show the loading skeleton when we genuinely have nothing
// to render. Read items.length from the ref so this stays a
// current-value check, not a subscription — see the comment on
// itemsLengthRef above for why.
if (showLoading && itemsLengthRef.current === 0) setIsLoading(true);
setError('');
try {
let data: OrderItem[] = [];
if (statusFilter === 'assigned') {
// Merge order_items (the original Assigned scope) with
// stock_purchase_items (status="assigned_to_driver"). Both
// get reshaped onto the same OrderItem shape with a `kind`
// discriminator so the existing card / modal / map code
// renders them identically — only the WRITE side branches
// on kind to hit the right PB collection.
//
// Pool + Problems stay unchanged: pool is system-wide open
// claims, problems are operator-side audit flags. Stock
// pickups don't have an open-claim state.
const [orders, stock] = await Promise.all([
getMyAssignedItems(),
getMyAssignedStockItems(),
]);
data = [...orders, ...stock];
} else if (statusFilter === 'collection_pool') {
data = await getPoolItems();
} else if (statusFilter === 'problem') {
data = await getProblemItems();
}
setItems(data);
setFilteredItems(data);
setCached(statusFilter, data);
// Default to ALL groups collapsed. Drivers see a flat list of
// stops (suppliers / area clusters / POs) on first load and tap
// one to drill into its items — matches the "open the app,
// see your day" mental model. The "Show all" toolbar action
// (`expandAll`) is still available for the rare case where a
// driver wants everything expanded at once.
setExpandedSuppliers(new Set());
setExpandedAreas(new Set());
setExpandedPOs(new Set());
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Pull down to retry.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [statusFilter]);
useEffect(() => { loadItems(); }, [loadItems]);
// Seed items from cache the moment the filter changes, BEFORE the
// network re-fetch resolves. This is what kills the
// skeleton-flash on tab/filter switches — driver taps Pool, the
// page immediately renders whatever was last cached for pool, and
// the re-fetch swaps in fresh data when it lands.
useEffect(() => {
const cached = getCached(statusFilter);
if (cached) {
setItems(cached);
setFilteredItems(cached);
}
// intentionally not depending on `items` — we only want this to
// fire when statusFilter changes (or on mount).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter]);
useEffect(() => {
let filtered = items;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((i) => {
const desc = i.description || '';
const supplier = i.expand?.supplier?.suppliername || '';
const orderNum = i.expand?.order?.order_number || '';
return (
desc.toLowerCase().includes(query) ||
supplier.toLowerCase().includes(query) ||
orderNum.toLowerCase().includes(query)
);
});
}
setFilteredItems(filtered);
}, [items, searchQuery, statusFilter]);
// Counts for the status chip strip — derived from the unfiltered
// list so chips stay stable as the user types in the search field.
// Each tab pulls from a different PB query (driver-scoped vs system-wide),
// so these counts aren't comparable across tabs; they each represent
// the size of the most recent fetch for THAT tab.
const statusCounts = useMemo(() => ({
assigned: items.filter((i) => i.item_status === 'assigned').length,
collection_pool: items.filter((i) => i.item_status === 'collection_pool').length,
problem: items.filter((i) => i.problem_flag).length,
}), [items]);
// Mirror the currently selected tab to localStorage so the Map page
// can pick it up on its next visit and show the same dataset the
// driver was last working with.
useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem('1driver_map_tab', statusFilter);
}
}, [statusFilter]);
const handleRefresh = async () => {
setIsRefreshing(true);
await loadItems(false);
};
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleCollect = async (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => {
try {
// expectedQty is computed from the same getItemDisplayQty the
// modal uses, so the comparison happens against the same number
// the driver just saw on screen.
const expectedQty = getItemDisplayQty(item);
if (item.kind === 'pickup') {
// Stock-pickup stop — PATCH stock_purchase_items. No photo
// upload on this side (stock_purchase_items has no
// attachments collection wired in PB).
await updateStockCollectionStatus(item.id, {
qty_collected: receivedQty,
notes,
expectedQty,
});
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast('Stock pickup collected successfully');
return;
}
const updated = await updateCollectionStatus(item.id, 'collected', {
qty_collected: receivedQty,
notes,
photo: null, // legacy collection_photo is no longer the home
// for the new photo — see uploadOrderItemAttachment
// below. Keeping the param `null` avoids clearing
// any previously-stored photo on the row.
expectedQty,
collected_date: new Date().toISOString(),
});
// Upload the photo, if any, into the order_item_attachments
// collection so the operator can audit the pickup with a
// visual record. We do this AFTER the status flip so a
// failure here doesn't strand the item in `assigned` —
// the collection is the more important state change.
if (photo) {
try {
await uploadOrderItemAttachment({
orderItemId: updated.id,
file: photo,
source: 'driver_proof',
notes: notes || undefined,
});
} catch (uploadErr) {
// Status already changed. Surface the failure so the
// driver knows the photo did NOT persist (a success
// toast here would be a lie). Known PB bug: image/* mime
// detection rejects camera JPEGs — until that server
// side detection is fixed, photos will keep failing.
console.error('Photo upload failed:', uploadErr);
const reason =
uploadErr instanceof Error ? uploadErr.message : 'unknown error';
showToast(
`Item collected — but photo upload failed (${reason})`,
'warning'
);
return; // skip the success toast that follows
}
}
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast(
photo
? 'Collection completed · photo uploaded'
: 'Collection completed successfully'
);
} catch (err) {
showToast('Failed to update collection', 'error');
}
};
const handleProblem = async (item: OrderItem, problemType: string, description: string) => {
try {
if (item.kind === 'pickup') {
await reportStockProblem(item.id, problemType, description);
} else {
await reportProblem(item.id, problemType, description);
}
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowProblemModal(false);
// Item is now in status='collection_pool' on PB, so it dropped
// off the Assigned tab server-side as well. Toast signposts the
// pool handoff so the driver knows the item will be re-attempted
// rather than just disappearing.
showToast('Problem reported — item sent back to the pool', 'warning');
} catch (err) {
showToast('Failed to report problem', 'error');
}
};
// 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 {
// Split the input by kind so order rows and stock rows go to
// their respective PB collections in one round-trip each.
const [orderResult, stockResult] = await Promise.all([
bulkCollect(itemsToCollect.filter((i) => i.kind !== 'pickup')),
bulkCollectStock(itemsToCollect),
]);
const ok = orderResult.ok + stockResult.ok;
const failed = orderResult.failed + stockResult.failed;
const ids = new Set(itemsToCollect.map((i) => i.id));
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
if (failed === 0) {
showToast(`Collected ${ok} item${ok === 1 ? '' : 's'} · ${poLabel}`, 'success');
} else {
showToast(
`Collected ${ok}, ${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 [orderResult, stockResult] = await Promise.all([
bulkProblem(itemsToFlag.filter((i) => i.kind !== 'pickup'), description),
bulkProblemStock(itemsToFlag, description),
]);
const ok = orderResult.ok + stockResult.ok;
const failed = orderResult.failed + stockResult.failed;
const ids = new Set(itemsToFlag.map((i) => i.id));
setItems((prev) => prev.filter((i) => !ids.has(i.id)));
if (failed === 0) {
showToast(
`Flagged ${ok} item${ok === 1 ? '' : 's'} · sent back to the pool · ${poLabel}`,
'warning'
);
} else {
showToast(
`Flagged ${ok}, ${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
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
};
const handleShowDetails = (item: OrderItem) => {
setSelectedItem(item);
setShowDetailModal(true);
};
const toggleSupplier = (supplierId: string) => {
setExpandedSuppliers(prev => {
const next = new Set(prev);
if (next.has(supplierId)) next.delete(supplierId);
else next.add(supplierId);
return next;
});
};
const toggleArea = (clusterId: string) => {
setExpandedAreas(prev => {
const next = new Set(prev);
if (next.has(clusterId)) next.delete(clusterId);
else next.add(clusterId);
return next;
});
};
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' || groupMode === 'po') {
const allIds = new Set<string>();
filteredItems.forEach(i => {
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
});
setExpandedSuppliers(allIds);
} else {
const allIds = new Set<string>();
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<string>();
filteredItems.forEach(i => {
const po = i.expand?.purchase_order;
allPoIds.add(po?.id || '__no_po__');
});
setExpandedPOs(allPoIds);
}
};
const collapseAll = () => {
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"
const groupedArray = useMemo(() => {
const grouped = filteredItems.reduce((acc, item) => {
const supplier = item.expand?.supplier;
const key = supplier?.id || '__unknown__';
if (!acc[key]) acc[key] = { supplier, items: [] };
acc[key].items.push(item);
return acc;
}, {} as Record<string, { supplier: NonNullable<OrderItem['expand']>['supplier'] | null; items: OrderItem[] }>);
return Object.values(grouped).sort((a, b) =>
(a.supplier?.suppliername || 'zzz').localeCompare(b.supplier?.suppliername || 'zzz')
);
}, [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<string, {
supplier: NonNullable<OrderItem['expand']>['supplier'] | null;
pos: Record<string, { poId: string; poNumber: string; items: OrderItem[] }>;
}> = {};
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);
}, [filteredItems]);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#f2f1ed]">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading...</p>
</div>
</div>
);
}
if (!isLoggedIn) return null;
return (
<div className="min-h-screen bg-[#f2f1ed] pb-nav">
{/* Header */}
<header
className="bg-[#e6e5e0] text-[#26251e] safe-top sticky top-0 z-40 border-b border-[rgba(38,37,30,0.1)] backdrop-blur-md"
role="banner"
style={{ boxShadow: '0 1px 0 rgba(38,37,30,0.06)' }}
>
<div className="brand-accent-top" aria-hidden="true" />
<div className="px-4 pt-2 pb-3">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold tracking-tight text-[#26251e]">Collections</h1>
<p className="text-[13px] text-[rgba(38,37,30,0.55)] truncate">{user?.name || user?.email}</p>
</div>
<div className="flex items-center gap-1.5">
<div className="relative">
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-[#f54e00] rounded-full border-2 border-[#e6e5e0] animate-pulse" aria-hidden="true" />
<button
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={isRefreshing ? 'Refreshing collections' : 'Refresh collections'}
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation"
>
<RefreshCw size={18} className={cn('text-[#26251e]', isRefreshing && 'animate-spin')} aria-hidden="true" />
</button>
</div>
<button
onClick={() => logout()}
aria-label="Sign out"
className="p-2.5 rounded-xl bg-[#ebeae5] active:bg-[#e1e0db] transition-all touch-manipulation"
>
<LogOut size={18} className="text-[#26251e]" aria-hidden="true" />
</button>
</div>
</div>
</div>
{/* Search bar */}
<div className="px-4 pb-2">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[rgba(38,37,30,0.55)] pointer-events-none" aria-hidden="true" />
<label htmlFor="collections-search" className="sr-only">Search collections</label>
<input
id="collections-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by item, supplier, order..."
className="w-full pl-9 pr-9 py-2.5 bg-[#f2f1ed] border border-[rgba(38,37,30,0.1)] rounded-xl text-sm text-[#26251e] placeholder:text-[rgba(38,37,30,0.4)] focus:outline-none focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] focus:bg-[#f7f7f4] transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-[rgba(38,37,30,0.55)] active:text-[#26251e]"
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
</div>
{/* Stats + Controls bar — wraps onto two lines on narrow viewports
so the stat chips don't collide with the toggle groups. On
≥640px everything fits on one line again. */}
<div className="px-4 pb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-2">
<div className="flex items-center gap-1.5 min-w-0 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#ebeae5] text-[11px] font-medium text-[#26251e]">
<Package size={12} aria-hidden="true" />
{items.length} item{items.length !== 1 ? 's' : ''}
</span>
{groupedArray.length > 0 && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#ebeae5] text-[11px] font-medium text-[#26251e]">
<MapPin size={12} aria-hidden="true" />
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
</span>
)}
</div>
{/* View toggle — buttons stay icon-only on the smallest screens
(label hidden at <sm) to keep the row from jumbling. */}
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap">
{/* Group mode toggle */}
<div
className="flex items-center gap-0.5 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
role="group"
aria-label="Group by"
>
<button
onClick={() => setGroupMode('supplier')}
aria-pressed={groupMode === 'supplier'}
aria-label="Group by supplier"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'supplier' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<MapPin size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Supplier</span>
</button>
<button
onClick={() => setGroupMode('po')}
aria-pressed={groupMode === 'po'}
aria-label="Group by invoice / purchase order"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'po' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<FileText size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Invoice</span>
</button>
<button
onClick={() => setGroupMode('area')}
aria-pressed={groupMode === 'area'}
aria-label="Group by area"
className={cn(
'px-2 py-1 rounded-md text-[11px] font-semibold transition-all flex items-center gap-1',
groupMode === 'area' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
<Layers size={10} aria-hidden="true" />
<span className="hidden xs:inline sm:inline">Area</span>
</button>
</div>
{/* View density toggle */}
<div
className="flex items-center gap-1 bg-[rgba(38,37,30,0.06)] rounded-lg p-0.5"
role="group"
aria-label="Item density"
>
<button
onClick={() => setViewMode('compact')}
aria-pressed={viewMode === 'compact'}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all',
viewMode === 'compact' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
Compact
</button>
<button
onClick={() => { setViewMode('detailed'); expandAll(); }}
aria-pressed={viewMode === 'detailed'}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-semibold transition-all',
viewMode === 'detailed' ? 'bg-white text-[#26251e] shadow-sm' : 'text-[rgba(38,37,30,0.6)]'
)}
>
Detailed
</button>
</div>
</div>
</div>
{/* Status filter chips — show counts of each state so drivers
can see at a glance what's pending vs flagged. */}
<div
className="px-4 pb-3 flex items-center gap-1.5 overflow-x-auto no-scrollbar -mx-1"
role="tablist"
aria-label="Filter by status"
>
{([
{ key: 'assigned', label: 'Assigned' },
{ key: 'collection_pool', label: 'Pool' },
{ key: 'problem', label: 'Problems' },
] as const).map(({ key, label }) => {
const count = statusCounts[key];
const active = statusFilter === key;
return (
<button
key={key}
role="tab"
aria-selected={active}
onClick={() => setStatusFilter(key)}
className={cn(
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold transition-all touch-manipulation flex-shrink-0',
active
? 'bg-[#26251e] text-[#f2f1ed]'
: 'bg-[rgba(38,37,30,0.06)] text-[rgba(38,37,30,0.7)] active:bg-[rgba(38,37,30,0.12)]'
)}
>
{label}
<span
className={cn(
'inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold',
active ? 'bg-[#f54e00] text-white' : 'bg-[rgba(38,37,30,0.08)] text-[rgba(38,37,30,0.6)]'
)}
aria-hidden="true"
>
{count}
</span>
</button>
);
})}
</div>
</header>
{/* Content */}
<main className="p-4 custom-scrollbar" role="main" aria-live="polite">
{isLoading ? (
<div className="space-y-3" aria-label="Loading your collections" aria-busy="true">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-[#e6e5e0] rounded-2xl p-4 border border-[rgba(38,37,30,0.1)]">
<div className="skeleton h-4 w-3/4 rounded-lg mb-3" />
<div className="skeleton h-3 w-1/2 rounded-lg mb-2" />
<div className="skeleton h-3 w-2/3 rounded-lg" />
</div>
))}
</div>
) : error ? (
<div className="text-center py-16 px-4" role="alert">
<div className="w-16 h-16 bg-[#fbeaef] rounded-2xl flex items-center justify-center mx-auto mb-4">
<AlertCircle size={32} className="text-[#b3224a]" aria-hidden="true" />
</div>
<p className="text-[#26251e] font-semibold mb-1">{error}</p>
<p className="text-[rgba(38,37,30,0.55)] text-sm mb-6">Check your connection and try again</p>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-2 px-6 py-3 bg-[#f54e00] text-white rounded-xl text-sm font-semibold active:bg-[#d44200] transition-colors touch-manipulation press-feedback min-h-[44px]"
style={{ boxShadow: '0 4px 12px rgba(245,78,0,0.3)' }}
>
<RefreshCw size={16} aria-hidden="true" />
Try Again
</button>
</div>
) : filteredItems.length === 0 ? (
<div className="text-center py-16 px-4">
<div className="w-16 h-16 bg-[#e6f1ec] rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={32} className="text-[#18714f]" aria-hidden="true" />
</div>
<p className="text-[#26251e] font-semibold text-lg mb-1">
{searchQuery ? 'Nothing matches' : 'All caught up'}
</p>
<p className="text-[rgba(38,37,30,0.55)] text-sm">
{searchQuery
? 'No collections match your search.'
: statusFilter === 'assigned'
? 'No pickups for today or yesterday. New ones appear here as the operator schedules them.'
: statusFilter === 'collection_pool'
? 'Collection pool is empty — nothing to claim right now.'
: 'No problems reported. Nice work.'}
</p>
</div>
) : (
<div className="space-y-4">
{groupMode === 'area' ? (
areaClusters.map((cluster, clusterIdx) => {
const color = SUPPLIER_COLORS[clusterIdx % SUPPLIER_COLORS.length];
const isExpanded = expandedAreas.has(cluster.id);
return (
<AreaGroup
key={cluster.id}
clusterId={cluster.id}
label={cluster.label}
items={cluster.items}
center={cluster.center}
color={color}
isExpanded={isExpanded}
viewMode={viewMode}
onToggle={() => toggleArea(cluster.id)}
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
/>
);
})
) : (
// Supplier + Invoice group modes share the SupplierGroup
// component; the PO sub-group render only kicks in when
// poGroups is non-empty (i.e. when groupMode === 'po').
// For 'supplier' mode the group carries items; for 'po'
// mode items is empty and pos carries the sub-buckets.
(() => {
const supplierGroups = groupMode === 'po'
? groupedByPO.map((g) => ({ supplier: g.supplier, items: [] as OrderItem[], pos: g.pos }))
: groupedArray.map((g) => ({ supplier: g.supplier, items: g.items, pos: [] as POSubGroup[] }));
return supplierGroups.map((group, groupIdx) => {
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
const isExpanded = expandedSuppliers.has(group.supplier!.id);
const isPoMode = groupMode === 'po';
return (
<SupplierGroup
key={group.supplier!.id}
supplier={group.supplier!}
items={group.items}
color={color}
isExpanded={isExpanded}
viewMode={viewMode}
onToggle={() => toggleSupplier(group.supplier!.id)}
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
poGroups={isPoMode ? group.pos : undefined}
expandedPOs={isPoMode ? expandedPOs : undefined}
onTogglePO={isPoMode ? togglePO : undefined}
onBulkCollect={isPoMode ? handleBulkCollect : undefined}
onBulkProblem={isPoMode ? handleBulkProblem : undefined}
/>
);
});
})()
)}
</div>
)}
</main>
{/* Modals */}
<CollectModal
item={selectedItem}
isOpen={showCollectModal}
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
onConfirm={handleCollect}
/>
<ProblemModal
item={selectedItem}
isOpen={showProblemModal}
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
onConfirm={handleProblem}
/>
<ItemDetailModal
item={selectedItem!}
purchaseOrder={selectedItem?.expand?.purchase_order ?? null}
isOpen={showDetailModal}
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
/>
{/* Toast */}
{toast && (
<div
className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top pointer-events-none"
role="status"
aria-live="polite"
>
<div className={cn(
'px-4 py-3 rounded-2xl text-sm font-medium text-white text-center pointer-events-auto',
toast.type === 'success' && 'bg-[#18714f]',
toast.type === 'error' && 'bg-[#cf2d56]',
toast.type === 'warning' && 'bg-[#d49103]'
)}
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18)' }}
>
{toast.message}
</div>
</div>
)}
<BottomNav />
</div>
);
}