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.
This commit is contained in:
SJCoetz 2026-05-27 00:55:21 +02:00
parent db10eb95c7
commit 2c896eede8
3 changed files with 337 additions and 71 deletions

View file

@ -1,12 +1,13 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
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 {
@ -26,9 +27,23 @@ import {
Search,
X,
Package,
ListFilter,
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();
@ -42,6 +57,10 @@ export default function CollectionsPage() {
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/');
@ -51,11 +70,16 @@ export default function CollectionsPage() {
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.');
@ -69,7 +93,6 @@ export default function CollectionsPage() {
useEffect(() => {
let filtered = items;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((i) => {
@ -83,7 +106,6 @@ export default function CollectionsPage() {
);
});
}
setFilteredItems(filtered);
}, [items, searchQuery]);
@ -108,7 +130,6 @@ export default function CollectionsPage() {
setShowCollectModal(false);
showToast('Collection completed successfully');
} catch (err) {
console.error('Failed to collect:', err);
showToast('Failed to update collection', 'error');
}
};
@ -120,7 +141,6 @@ export default function CollectionsPage() {
setShowProblemModal(false);
showToast('Problem reported successfully', 'warning');
} catch (err) {
console.error('Failed to report problem:', err);
showToast('Failed to report problem', 'error');
}
};
@ -133,21 +153,40 @@ export default function CollectionsPage() {
window.open(url, '_blank');
};
// Group by supplier
const groupedBySupplier = 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[] }>);
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 groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
);
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 (
@ -196,7 +235,7 @@ export default function CollectionsPage() {
</div>
{/* Search bar */}
<div className="px-4 pb-3">
<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
@ -217,18 +256,42 @@ export default function CollectionsPage() {
</div>
</div>
{/* Stats pill */}
<div className="px-4 pb-2 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 && (
{/* 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">
<MapPin size={12} />
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
<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>
@ -236,8 +299,8 @@ export default function CollectionsPage() {
<main className="p-4 custom-scrollbar">
{isLoading ? (
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white rounded-2xl p-4 card-shadow stagger-1">
{[...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" />
@ -270,39 +333,26 @@ export default function CollectionsPage() {
</p>
</div>
) : (
<div className="space-y-6">
{groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => (
<div key={supplier!.id} className="animate-fade-in" style={{ animationDelay: `${groupIdx * 0.05}s` }}>
{/* Supplier header */}
<div className="flex items-center gap-2.5 mb-3 px-1">
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
<MapPin size={14} className="text-orange-600" />
</div>
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-sm text-gray-900 truncate">{supplier!.suppliername}</h2>
{supplier!.supplieraddress && (
<p className="text-[11px] text-gray-400 truncate">{supplier!.supplieraddress}</p>
)}
</div>
<span className="bg-orange-500 text-white text-[10px] px-2 py-0.5 rounded-full font-bold">
{supplierItems.length}
</span>
</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);
{/* Items */}
<div className="space-y-2.5">
{supplierItems.map((item) => (
<CollectionItemCard
key={item.id}
item={item}
onCollect={(i) => { setSelectedItem(i); setShowCollectModal(true); }}
onProblem={(i) => { setSelectedItem(i); setShowProblemModal(true); }}
onNavigate={handleNavigate}
/>
))}
</div>
</div>
))}
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>
@ -314,7 +364,6 @@ export default function CollectionsPage() {
onClose={() => { setShowCollectModal(false); setSelectedItem(null); }}
onConfirm={handleCollect}
/>
<ProblemModal
item={selectedItem}
isOpen={showProblemModal}
@ -326,7 +375,7 @@ export default function CollectionsPage() {
{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 press-feedback-sm',
'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'

View file

@ -20,6 +20,8 @@ import {
interface CollectionItemProps {
item: OrderItem;
supplierColor?: { border: string; dot: string; light: string; text: string; ring: string };
compact?: boolean;
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
@ -85,6 +87,8 @@ function getItemType(item: OrderItem): {
export default function CollectionItemCard({
item,
supplierColor,
compact,
onCollect,
onProblem,
onNavigate,
@ -97,13 +101,16 @@ export default function CollectionItemCard({
const hasCoords = coords !== null;
const description = item.description || 'Unknown item';
const expectedQty = item.order_qty || item.quantity || 0;
const colorAccent = supplierColor?.border || 'border-l-orange-400';
return (
<div
className={cn(
'bg-white rounded-2xl card-shadow card-press overflow-hidden',
(item.item_status === 'problem' || item.problem_flag) && 'ring-l-4 ring-red-400',
item.item_status === 'assigned' && 'border-l-[3px] border-orange-400'
compact
? 'bg-gray-50 rounded-xl overflow-hidden'
: 'bg-white rounded-2xl card-shadow card-press overflow-hidden',
(item.item_status === 'problem' || item.problem_flag) && 'ring-1 ring-red-400',
item.item_status === 'assigned' && !compact && `border-l-[3px] ${colorAccent}`
)}
>
{/* Header */}

View file

@ -0,0 +1,210 @@
'use client';
import { OrderItem, Supplier } from '@/lib/types';
import { cn, formatCurrency, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
import {
ChevronDown,
ChevronUp,
MapPin,
Navigation,
Package,
ShoppingCart,
Hammer,
Wrench,
AlertTriangle,
} from 'lucide-react';
interface SupplierColor {
border: string;
dot: string;
light: string;
text: string;
ring: string;
}
interface SupplierGroupProps {
supplier: Supplier;
items: OrderItem[];
color: SupplierColor;
isExpanded: boolean;
viewMode: 'compact' | 'detailed';
onToggle: () => void;
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
}
function getItemIcon(item: OrderItem) {
if (item.item_status === 'problem' || item.problem_flag) return <AlertTriangle size={12} className="text-red-500" />;
const orderNumber = item.expand?.order?.order_number || '';
if (orderNumber.toLowerCase().includes('breakdown')) return <Hammer size={12} className="text-orange-500" />;
if (orderNumber.toLowerCase().includes('repair')) return <Wrench size={12} className="text-amber-500" />;
if (item.is_stock_item) return <Package size={12} className="text-violet-500" />;
return <ShoppingCart size={12} className="text-gray-400" />;
}
export default function SupplierGroup({
supplier,
items,
color,
isExpanded,
viewMode,
onToggle,
onCollect,
onProblem,
onNavigate,
}: SupplierGroupProps) {
const coords = parseCoordinates(supplier.coordinates);
const hasCoords = coords !== null;
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
return (
<div
className={cn(
'bg-white rounded-2xl card-shadow overflow-hidden transition-all duration-200',
`border-l-[4px] ${color.border}`
)}
>
{/* Supplier Header — always visible, tap to expand/collapse */}
<button
onClick={onToggle}
className="w-full text-left p-4 active:bg-gray-50/50 transition-colors"
>
<div className="flex items-center gap-3">
{/* Color dot */}
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', color.light)}>
<MapPin size={16} className={color.text} />
</div>
<div className="flex-1 min-w-0">
<h2 className={cn('font-semibold text-sm truncate leading-snug', color.text)}>
{supplier.suppliername}
</h2>
{supplier.supplieraddress && (
<p className="text-[11px] text-gray-400 truncate mt-0.5">{supplier.supplieraddress}</p>
)}
</div>
{/* Right side info */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-col items-end gap-0.5">
<span className={cn('text-[11px] font-bold', color.text)}>
{items.length} item{items.length !== 1 ? 's' : ''}
</span>
<span className="text-[10px] text-gray-400">{totalQty} total</span>
</div>
<div className="flex items-center justify-center w-7 h-7">
{isExpanded ? (
<ChevronUp size={18} className="text-gray-400" />
) : (
<ChevronDown size={18} className="text-gray-400" />
)}
</div>
</div>
</div>
{/* Compact preview line (only when collapsed in compact mode) */}
{viewMode === 'compact' && !isExpanded && (
<div className="mt-2 ml-12 flex items-center gap-1.5 flex-wrap">
{items.slice(0, 3).map((item, i) => (
<span key={item.id} className="text-[11px] text-gray-500 truncate max-w-[140px]">
{item.description || 'Item'}
{i < Math.min(items.length, 3) - 1 && ','}
</span>
))}
{items.length > 3 && (
<span className="text-[10px] text-gray-400 font-medium">
+{items.length - 3} more
</span>
)}
</div>
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-gray-100 animate-fade-in">
{viewMode === 'compact' ? (
/* COMPACT MODE: simple list with item name + qty */
<div className="divide-y divide-gray-50">
{items.map((item) => {
const expectedQty = item.order_qty || item.quantity || 0;
return (
<div
key={item.id}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50/50 transition-colors cursor-pointer active:bg-gray-100"
onClick={() => {
// Determine if we should open collect or problem modal
// For now, just expand - user can use the action buttons
}}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
{getItemIcon(item)}
<span className="text-sm text-gray-800 truncate">
{item.description || 'Unknown item'}
</span>
{item.item_status === 'problem' && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-700 font-medium flex-shrink-0">
Problem
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-2">
<span className="text-sm font-semibold text-gray-700">{expectedQty}×</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); onCollect(item); }}
className="w-8 h-8 rounded-lg bg-green-500 text-white flex items-center justify-center active:bg-green-600 transition-colors touch-manipulation"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onProblem(item); }}
className="w-8 h-8 rounded-lg bg-red-500 text-white flex items-center justify-center active:bg-red-600 transition-colors touch-manipulation"
>
<AlertTriangle size={14} />
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
/* DETAILED MODE: full item cards */
<div className="p-3 space-y-2">
{items.map((item) => (
<CollectionItemCard
key={item.id}
item={item}
supplierColor={color}
compact
onCollect={onCollect}
onProblem={onProblem}
onNavigate={onNavigate}
/>
))}
</div>
)}
{/* Supplier footer with navigate button */}
{hasCoords && coords && (
<div className="px-4 pb-3">
<button
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
className={cn(
'w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white transition-all touch-manipulation press-feedback',
color.dot
)}
>
<Navigation size={14} />
Navigate to {supplier.suppliername}
</button>
</div>
)}
</div>
)}
</div>
);
}