WFDASHDRIVER-APP/app/page.tsx
SJCoetz 85c63b336c feat: add PO/Notes popovers, supplier color sidebar, fix popover clipping
- Add ItemDetailModal showing PO document (view/download/share/email) + buyer notes
- Add FileText (PO) and MessageSquare (notes) buttons to compact item list with popovers
- Fix photo upload: append to order_items.order_item_attachements (PB file field)
- Add colored left sidebar stripe per supplier group for visual distinction
- Fix popover z-index so they appear above following cards (overflow-visible + z-50)
- Extend PurchaseOrder type with documents, email_payload, supplier
- Add order_item_attachements and expand.purchase_order to OrderItem
- onShowDetails prop wired through SupplierGroup and AreaGroup

Co-authored-by: Orca <help@stably.ai>
2026-08-02 21:38:38 +02:00

685 lines
No EOL
29 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useMemo } 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 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,
getPoolItems,
getProblemItems,
updateCollectionStatus,
uploadOrderItemAttachment,
reportProblem,
isAuthenticated,
logout,
getItemDisplayQty,
} 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,
} 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' | '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());
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. If cached data is on screen, the re-fetch happens
// silently and the user sees fresh data appear without a flash.
if (showLoading && items.length === 0) setIsLoading(true);
setError('');
try {
let data: OrderItem[] = [];
if (statusFilter === 'assigned') {
data = await getMyAssignedItems();
} else if (statusFilter === 'collection_pool') {
data = await getPoolItems();
} else if (statusFilter === 'problem') {
data = await getProblemItems();
}
setItems(data);
setFilteredItems(data);
setCached(statusFilter, data);
const allSupplierIds = new Set<string>();
data.forEach(i => {
if (i.expand?.supplier?.id) allSupplierIds.add(i.expand.supplier.id);
});
setExpandedSuppliers(allSupplierIds);
const allAreaIds = new Set<string>();
const clusters = clusterByProximity(data, 2);
clusters.forEach(c => allAreaIds.add(c.id));
setExpandedAreas(allAreaIds);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Pull down to retry.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [statusFilter, items.length]);
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);
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 {
await reportProblem(item.id, problemType, description);
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowProblemModal(false);
showToast('Problem reported successfully', 'warning');
} catch (err) {
showToast('Failed to report problem', '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 expandAll = () => {
if (groupMode === 'supplier') {
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);
}
};
const collapseAll = () => {
if (groupMode === 'supplier') {
setExpandedSuppliers(new Set());
} else {
setExpandedAreas(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 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 */}
<div className="px-4 pb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<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 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 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'}
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" />
Supplier
</button>
<button
onClick={() => setGroupMode('area')}
aria-pressed={groupMode === '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" />
Area
</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 assigned collections right now. New pickups will appear here.'
: 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 === 'supplier' ? (
groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => {
const color = SUPPLIER_COLORS[groupIdx % SUPPLIER_COLORS.length];
const isExpanded = expandedSuppliers.has(supplier!.id);
return (
<SupplierGroup
key={supplier!.id}
supplier={supplier!}
items={supplierItems}
color={color}
isExpanded={isExpanded}
viewMode={viewMode}
onToggle={() => toggleSupplier(supplier!.id)}
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
/>
);
})
) : (
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}
/>
);
})
)}
</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>
);
}