2026-07-07 15:15:42 +00:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-08-02 20:42:40 +00:00
|
|
|
|
import { useState } from 'react';
|
2026-07-07 15:15:42 +00:00
|
|
|
|
import { OrderItem } 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;
|
2026-08-02 19:38:38 +00:00
|
|
|
|
onShowDetails: (item: OrderItem) => void;
|
2026-07-07 15:15:42 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-02 19:38:38 +00:00
|
|
|
|
onShowDetails,
|
2026-07-07 15:15:42 +00:00
|
|
|
|
}: 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;
|
|
|
|
|
|
|
2026-08-02 20:42:40 +00:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
className={cn(
|
2026-08-02 20:42:40 +00:00
|
|
|
|
'relative rounded-2xl overflow-hidden transition-all duration-200 border',
|
2026-07-07 15:15:42 +00:00
|
|
|
|
color.light,
|
|
|
|
|
|
`border-[rgba(38,37,30,0.1)]`,
|
2026-08-02 20:42:40 +00:00
|
|
|
|
`ring-1 ${color.ring}`,
|
2026-08-02 20:58:08 +00:00
|
|
|
|
// 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'
|
2026-07-07 15:15:42 +00:00
|
|
|
|
)}
|
|
|
|
|
|
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">
|
2026-07-10 00:01:57 +00:00
|
|
|
|
<h2 className={cn('font-bold text-[17px] leading-snug', color.text)}>
|
2026-07-07 15:15:42 +00:00
|
|
|
|
{label}
|
|
|
|
|
|
</h2>
|
2026-07-10 00:01:57 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-07 15:15:42 +00:00
|
|
|
|
</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="flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] 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-[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}
|
2026-08-02 19:38:38 +00:00
|
|
|
|
onShowDetails={onShowDetails}
|
2026-08-02 20:42:40 +00:00
|
|
|
|
onPopoverChange={setChildPopoverOpen}
|
2026-07-07 15:15:42 +00:00
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|