Every flagged item rendered the same red triangle and the same "Problem" pill, whichever problem it was. An out-of-stock line and a quality complaint look identical on the list, and they need different follow-ups — one is a phone call to the supplier, the other is a replacement. problem_type is a PB *select* on order_items (stock|supplier|driver| delivery|pricing|other, plus the four values added 2026-10-14) but a free *text* field on stock_purchase_items, so the label map is deliberately wider than PROBLEM_TYPES: updateStockCollectionStatus writes `stock` on its own, and stock rows can carry anything. Out-of-stock rows also get a red left rail and a light red wash. The pill stays the signal — colour is never the only thing carrying the meaning, since a red wash alone is invisible to anyone who can't distinguish it from the hover state. Colour is deliberately NOT used to disable Collect. Stock can arrive without anyone clearing the flag, and a driver standing in front of the shelf is the best possible source of truth.
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { OrderItem, isOutOfStock, problemBadgeLabel } from '@/lib/types';
|
||
import { cn } from '@/lib/utils';
|
||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
MapPin,
|
||
Navigation,
|
||
Package,
|
||
ShoppingCart,
|
||
Hammer,
|
||
Wrench,
|
||
AlertTriangle,
|
||
Layers,
|
||
} from 'lucide-react';
|
||
|
||
interface AreaColor {
|
||
dot: string;
|
||
light: string;
|
||
text: string;
|
||
ring: string;
|
||
}
|
||
|
||
interface AreaGroupProps {
|
||
clusterId: string;
|
||
label: string;
|
||
items: OrderItem[];
|
||
center: { latitude: number; longitude: number };
|
||
color: AreaColor;
|
||
isExpanded: boolean;
|
||
viewMode: 'compact' | 'detailed';
|
||
onToggle: () => void;
|
||
onCollect: (item: OrderItem) => void;
|
||
onProblem: (item: OrderItem) => void;
|
||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||
onShowDetails: (item: OrderItem) => void;
|
||
}
|
||
|
||
function getItemIcon(item: OrderItem) {
|
||
if (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 AreaGroup({
|
||
clusterId,
|
||
label,
|
||
items,
|
||
center,
|
||
color,
|
||
isExpanded,
|
||
viewMode,
|
||
onToggle,
|
||
onCollect,
|
||
onProblem,
|
||
onNavigate,
|
||
onShowDetails,
|
||
}: AreaGroupProps) {
|
||
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
||
|
||
const uniqueSuppliers = Array.from(
|
||
new Set(
|
||
items
|
||
.map(i => i.expand?.supplier?.suppliername)
|
||
.filter((n): n is string => !!n)
|
||
)
|
||
);
|
||
|
||
const hasCoords = center.latitude !== 0 && center.longitude !== 0;
|
||
|
||
// Tracks whether any detailed-mode CollectionItemCard inside has
|
||
// an open popover. We use this to lift the section's stacking
|
||
// context so the popover paints above the next area group.
|
||
const [childPopoverOpen, setChildPopoverOpen] = useState(false);
|
||
|
||
return (
|
||
<section
|
||
className={cn(
|
||
'relative rounded-2xl overflow-hidden transition-all duration-200 border',
|
||
color.light,
|
||
`border-[rgba(38,37,30,0.1)]`,
|
||
`ring-1 ${color.ring}`,
|
||
// z-[80] (above BottomNav's z-50) when a card popover is open
|
||
// so the popover can paint above the bottom chrome. The
|
||
// popover's own z-[60] is *inside* this section's stacking
|
||
// context, so without this lift the popover would be hidden
|
||
// behind the BottomNav.
|
||
childPopoverOpen ? 'z-[80]' : 'z-10'
|
||
)}
|
||
aria-label={`${label} area group`}
|
||
>
|
||
<button
|
||
onClick={onToggle}
|
||
aria-expanded={isExpanded}
|
||
aria-controls={`area-${clusterId}-items`}
|
||
className="w-full text-left p-4 active:bg-[#ebeae5] transition-colors"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', color.dot)} aria-hidden="true">
|
||
<Layers size={16} className="text-white" />
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className={cn('font-bold text-[17px] leading-snug', color.text)}>
|
||
{label}
|
||
</h2>
|
||
{uniqueSuppliers.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||
{uniqueSuppliers.map((name) => (
|
||
<span
|
||
key={name}
|
||
className={cn(
|
||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[12px] font-semibold',
|
||
color.light,
|
||
color.text,
|
||
`ring-1 ${color.ring}`
|
||
)}
|
||
>
|
||
<MapPin size={10} aria-hidden="true" />
|
||
{name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<div className="flex flex-col items-end gap-0.5">
|
||
<span className="text-[12px] font-bold text-[#26251e]">
|
||
{items.length} item{items.length !== 1 ? 's' : ''}
|
||
</span>
|
||
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">{totalQty} total</span>
|
||
</div>
|
||
<div className="flex items-center justify-center w-7 h-7" aria-hidden="true">
|
||
{isExpanded ? (
|
||
<ChevronUp size={18} className="text-[rgba(38,37,30,0.55)]" />
|
||
) : (
|
||
<ChevronDown size={18} className="text-[rgba(38,37,30,0.55)]" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{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-[rgba(38,37,30,0.6)] truncate max-w-[140px]">
|
||
{item.description || 'Item'}
|
||
{i < Math.min(items.length, 3) - 1 && ','}
|
||
</span>
|
||
))}
|
||
{items.length > 3 && (
|
||
<span className="text-[10px] text-[rgba(38,37,30,0.4)] font-medium">
|
||
+{items.length - 3} more
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div
|
||
id={`area-${clusterId}-items`}
|
||
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
||
>
|
||
{viewMode === 'compact' ? (
|
||
<ul className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
||
{items.map((item) => {
|
||
const expectedQty = item.order_qty || item.quantity || 0;
|
||
return (
|
||
<li
|
||
key={item.id}
|
||
className={cn(
|
||
'flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors',
|
||
// Out-of-stock rows carry a red left rail + tint so
|
||
// they read as blocked without relying on the pill
|
||
// alone. Colour is never the only signal — the
|
||
// "Out of stock" pill is always rendered too.
|
||
isOutOfStock(item) && 'bg-[#fdf2f4] border-l-[3px] border-l-[#cf2d56] pl-[13px]'
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||
{getItemIcon(item)}
|
||
<div className="min-w-0 flex-1">
|
||
<span className="text-sm text-[#26251e] truncate block">
|
||
{item.description || 'Unknown item'}
|
||
</span>
|
||
{item.expand?.supplier?.suppliername && (
|
||
<span className="text-[10px] text-[rgba(38,37,30,0.45)]">
|
||
{item.expand.supplier.suppliername}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{item.problem_flag && (
|
||
<span
|
||
className={cn(
|
||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0',
|
||
isOutOfStock(item)
|
||
? 'bg-[#cf2d56] text-white'
|
||
: 'bg-[#fbeaef] text-[#8a1b3a]'
|
||
)}
|
||
>
|
||
{problemBadgeLabel(item)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3 flex-shrink-0 ml-2">
|
||
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => onCollect(item)}
|
||
aria-label={`Collect ${item.description || 'item'}`}
|
||
className="w-10 h-10 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||
</button>
|
||
<button
|
||
onClick={() => onProblem(item)}
|
||
aria-label={`Report problem for ${item.description || 'item'}`}
|
||
className="w-10 h-10 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1b3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||
>
|
||
<AlertTriangle size={16} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<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}
|
||
onShowDetails={onShowDetails}
|
||
onPopoverChange={setChildPopoverOpen}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasCoords && (
|
||
<div className="px-4 pb-3">
|
||
<button
|
||
onClick={() => onNavigate(center.latitude, center.longitude, label)}
|
||
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 min-h-[44px]',
|
||
color.dot
|
||
)}
|
||
>
|
||
<Navigation size={14} aria-hidden="true" />
|
||
Navigate to {label}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|