WFDASHDRIVER-APP/components/AreaGroup.tsx
Dave 286b86162b feat(problems): say what kind of problem, and flag out-of-stock rows
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.
2026-10-15 08:24:58 +02:00

271 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}