WFDASHDRIVER-APP/app/page.tsx
SJCoetz 2c896eede8 Add SupplierGroup and supplier view modes
Introduce a new SupplierGroup component and group collections by supplier. Add supplier color palette, per-supplier expand/collapse state, compact/detailed view modes, and controls to toggle/expand/collapse groups. Pass supplierColor and compact props to CollectionItemCard to adjust visuals, use useMemo for grouping, and auto-expand suppliers on initial load. Minor UI tweaks: adjust skeleton count/spacing, update stats/controls bar, improve toast styling, and remove some console logs.
2026-05-27 00:55:21 +02:00

391 lines
No EOL
15 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 LocationTracker from '@/components/LocationTracker';
import CollectionItemCard from '@/components/CollectionItemCard';
import SupplierGroup from '@/components/SupplierGroup';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import {
getAssignedCollections,
updateCollectionStatus,
reportProblem,
isAuthenticated,
logout,
} from '@/lib/pocketbase';
import { cn } from '@/lib/utils';
import {
RefreshCw,
LogOut,
MapPin,
AlertCircle,
CheckCircle2,
Search,
X,
Package,
LayoutList,
Columns,
} from 'lucide-react';
const SUPPLIER_COLORS = [
{ border: 'border-l-orange-500', dot: 'bg-orange-500', light: 'bg-orange-50', text: 'text-orange-700', ring: 'ring-orange-500/30' },
{ border: 'border-l-blue-500', dot: 'bg-blue-500', light: 'bg-blue-50', text: 'text-blue-700', ring: 'ring-blue-500/30' },
{ border: 'border-l-emerald-500', dot: 'bg-emerald-500', light: 'bg-emerald-50', text: 'text-emerald-700', ring: 'ring-emerald-500/30' },
{ border: 'border-l-violet-500', dot: 'bg-violet-500', light: 'bg-violet-50', text: 'text-violet-700', ring: 'ring-violet-500/30' },
{ border: 'border-l-rose-500', dot: 'bg-rose-500', light: 'bg-rose-50', text: 'text-rose-700', ring: 'ring-rose-500/30' },
{ border: 'border-l-cyan-500', dot: 'bg-cyan-500', light: 'bg-cyan-50', text: 'text-cyan-700', ring: 'ring-cyan-500/30' },
{ border: 'border-l-amber-500', dot: 'bg-amber-500', light: 'bg-amber-50', text: 'text-amber-700', ring: 'ring-amber-500/30' },
{ border: 'border-l-pink-500', dot: 'bg-pink-500', light: 'bg-pink-50', text: 'text-pink-700', ring: 'ring-pink-500/30' },
{ border: 'border-l-teal-500', dot: 'bg-teal-500', light: 'bg-teal-50', text: 'text-teal-700', ring: 'ring-teal-500/30' },
{ border: 'border-l-indigo-500', dot: 'bg-indigo-500', light: 'bg-indigo-50', text: 'text-indigo-700', ring: 'ring-indigo-500/30' },
];
export default function CollectionsPage() {
const router = useRouter();
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
const [items, setItems] = useState<OrderItem[]>([]);
const [filteredItems, setFilteredItems] = useState<OrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
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 [showProblemModal, setShowProblemModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
// Track which supplier groups are expanded
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(new Set());
useEffect(() => {
if (!authLoading && !isLoggedIn) router.push('/login/');
}, [isLoggedIn, authLoading, router]);
const loadItems = useCallback(async (showLoading = true) => {
if (!isAuthenticated()) return;
if (showLoading) setIsLoading(true);
setError('');
try {
const data = await getAssignedCollections();
setItems(data);
setFilteredItems(data);
// Auto-expand all suppliers on fresh load
const allIds = new Set<string>();
data.forEach(i => {
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
});
setExpandedSuppliers(allIds);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Pull down to retry.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => { loadItems(); }, [loadItems]);
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]);
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) => {
try {
await updateCollectionStatus(item.id, 'collected', {
qty_collected: receivedQty,
notes,
collected_date: new Date().toISOString(),
});
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast('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 toggleSupplier = (supplierId: string) => {
setExpandedSuppliers(prev => {
const next = new Set(prev);
if (next.has(supplierId)) next.delete(supplierId);
else next.add(supplierId);
return next;
});
};
const expandAll = () => {
const allIds = new Set<string>();
filteredItems.forEach(i => {
if (i.expand?.supplier?.id) allIds.add(i.expand.supplier.id);
});
setExpandedSuppliers(allIds);
};
const collapseAll = () => setExpandedSuppliers(new Set());
// Group by supplier
const groupedArray = useMemo(() => {
const grouped = filteredItems.reduce((acc, item) => {
const supplier = item.expand?.supplier;
if (!supplier) return acc;
const key = supplier.id;
if (!acc[key]) acc[key] = { supplier, items: [] };
acc[key].items.push(item);
return acc;
}, {} as Record<string, { supplier: NonNullable<OrderItem['expand']>['supplier']; items: OrderItem[] }>);
return Object.values(grouped).sort((a, b) =>
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
);
}, [filteredItems]);
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isLoggedIn) return null;
return (
<div className="min-h-screen bg-gray-50 pb-nav">
<LocationTracker />
{/* Header */}
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-lg shadow-orange-600/20">
<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">Collections</h1>
<p className="text-[13px] text-orange-100 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-green-400 rounded-full border-2 border-orange-600 animate-pulse" />
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
>
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
</button>
</div>
<button
onClick={() => logout()}
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
>
<LogOut size={18} />
</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-white/50" />
<input
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-white/15 backdrop-blur-sm border border-white/20 rounded-xl text-sm text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 focus:bg-white/20 transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 active:text-white"
>
<X size={16} />
</button>
)}
</div>
</div>
{/* Stats + Controls bar */}
<div className="px-4 pb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-[11px] font-medium">
<Package size={12} />
{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-white/15 text-[11px] font-medium">
<MapPin size={12} />
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
</span>
)}
</div>
{/* View toggle */}
<div className="flex items-center gap-1 bg-white/10 rounded-lg p-0.5">
<button
onClick={() => setViewMode('compact')}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-medium transition-all',
viewMode === 'compact' ? 'bg-white text-orange-600 shadow-sm' : 'text-white/70'
)}
>
Compact
</button>
<button
onClick={() => { setViewMode('detailed'); expandAll(); }}
className={cn(
'px-2.5 py-1 rounded-md text-[11px] font-medium transition-all',
viewMode === 'detailed' ? 'bg-white text-orange-600 shadow-sm' : 'text-white/70'
)}
>
Detailed
</button>
</div>
</div>
</header>
{/* Content */}
<main className="p-4 custom-scrollbar">
{isLoading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white rounded-2xl p-4 card-shadow">
<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">
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
<AlertCircle size={32} className="text-red-400" />
</div>
<p className="text-gray-600 font-medium mb-1">{error}</p>
<p className="text-gray-400 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-orange-500 text-white rounded-xl text-sm font-semibold active:bg-orange-600 transition-colors touch-manipulation press-feedback"
>
<RefreshCw size={16} />
Try Again
</button>
</div>
) : groupedArray.length === 0 ? (
<div className="text-center py-16 px-4">
<div className="w-16 h-16 bg-green-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={32} className="text-green-400" />
</div>
<p className="text-gray-700 font-semibold text-lg mb-1">All caught up!</p>
<p className="text-gray-400 text-sm">
{searchQuery ? 'No collections match your search' : 'No assigned collections right now'}
</p>
</div>
) : (
<div className="space-y-4">
{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}
/>
);
})}
</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}
/>
{/* Toast */}
{toast && (
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
<div className={cn(
'px-4 py-3 rounded-2xl shadow-lg text-sm font-medium text-white text-center',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'warning' && 'bg-amber-500'
)}>
{toast.message}
</div>
</div>
)}
<BottomNav />
</div>
);
}