WFDASHDRIVER-APP/components/AreaGroup.tsx
SJCoetz 105f169e45 feat: popover stays above footer, partial collections must be justified
Two improvements to the collection flow:

1. Popover clipping under BottomNav (last item in list)
   - Root cause: popovers were z-[60] *inside* a parent stacking
     context at z-30, so their effective page z-index was 30 — below
     BottomNav's z-50. Even if the z-index were right, the popover
     extends below the trigger button and could fall below the
     viewport on the last item in a long list.
   - Add lib/hooks.ts with usePopoverPlacement: measures the
     trigger's bottom edge and flips popover direction to "up" when
     there isn't ~240px of room below (reserving 80px for BottomNav
     + safe-area inset).
   - Bump the article/section/li stacking context from z-30 to z-[80]
     when a popover is open so the popover paints above BottomNav
     (z-50). Covers both compact-mode SupplierGroup rows and
     detailed-mode CollectionItemCard via the existing
     onPopoverChange callback.
   - For SupplierGroup: hooks live at the top level (record keyed by
     item.id) to avoid the Rules of Hooks trap of declaring hooks
     inside items.map().

2. Partial collections must be justified
   - In CollectModal, when received qty < expected, require EITHER a
     reason (dropdown: out of stock / wrong item / quality issue /
     damaged / wrong qty packed / other) OR a photo before Confirm
     is enabled. Prevents silent partial submissions with no audit
     trail.
   - The picked reason flows into the auto-generated problem note
     so the operator sees "[Reason] driver note" in PB.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-02 22:58:08 +02:00

257 lines
9.7 KiB
TypeScript
Raw 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 } 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="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}
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>
);
}