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.
589 lines
No EOL
26 KiB
TypeScript
589 lines
No EOL
26 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { OrderItem, Supplier, isOutOfStock, problemBadgeLabel } from '@/lib/types';
|
||
import { cn, parseCoordinates } from '@/lib/utils';
|
||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
MapPin,
|
||
Navigation,
|
||
Package,
|
||
ShoppingCart,
|
||
Hammer,
|
||
Wrench,
|
||
AlertTriangle,
|
||
FileText,
|
||
MessageSquare,
|
||
ExternalLink,
|
||
CheckCheck,
|
||
} from 'lucide-react';
|
||
|
||
interface SupplierColor {
|
||
dot: string;
|
||
border: string;
|
||
light: string;
|
||
text: string;
|
||
ring: string;
|
||
}
|
||
|
||
// PO sub-group rendered inside a supplier section when groupMode is
|
||
// 'po'. Each sub-group is collapsible, has a "mark all" affordance,
|
||
// and shows the items it owns as the standard compact rows.
|
||
export interface POSubGroup {
|
||
poId: string;
|
||
poNumber: string;
|
||
items: OrderItem[];
|
||
}
|
||
|
||
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;
|
||
onShowDetails: (item: OrderItem) => void;
|
||
// When provided, the supplier section renders its items bucketed by
|
||
// PO sub-group instead of as a single flat list. Each sub-group can
|
||
// be collapsed independently via expandedPOs, and bulk actions
|
||
// target the items inside the tapped sub-group.
|
||
poGroups?: POSubGroup[];
|
||
expandedPOs?: Set<string>;
|
||
onTogglePO?: (poId: string) => void;
|
||
onBulkCollect?: (poLabel: string, items: OrderItem[]) => void;
|
||
onBulkProblem?: (poLabel: string, items: 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" />;
|
||
}
|
||
|
||
// Compact row markup extracted so the flat-list render and the
|
||
// per-PO sub-group render can both share it without duplicating ~180
|
||
// lines of popover + tooltip + button code. Tooltip state lives one
|
||
// level up so click-outside detection on the wrapping <ul> still works
|
||
// when rows are nested inside multiple PO sub-groups.
|
||
interface CompactItemRowProps {
|
||
item: OrderItem;
|
||
activeTooltip: { itemId: string; type: 'po' | 'notes' } | null;
|
||
setActiveTooltip: (t: { itemId: string; type: 'po' | 'notes' } | null) => void;
|
||
rowPlacement: 'down' | 'up';
|
||
measureRowPlacement: (itemId: string, btn: HTMLElement | null) => void;
|
||
onShowDetails: (item: OrderItem) => void;
|
||
onCollect: (item: OrderItem) => void;
|
||
onProblem: (item: OrderItem) => void;
|
||
}
|
||
|
||
function CompactItemRow({
|
||
item,
|
||
activeTooltip,
|
||
setActiveTooltip,
|
||
rowPlacement,
|
||
measureRowPlacement,
|
||
onShowDetails,
|
||
onCollect,
|
||
onProblem,
|
||
}: CompactItemRowProps) {
|
||
const expectedQty = item.order_qty || item.quantity || 0;
|
||
const po = item.expand?.purchase_order;
|
||
const poNumber = po?.po_number;
|
||
const orderNumber = item.expand?.order?.order_number;
|
||
const hasNotes = Boolean(item.notes);
|
||
const hasProblemNotes = Boolean(item.problem_notes);
|
||
const hasAnyNotes = hasNotes || hasProblemNotes;
|
||
const isPoOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'po';
|
||
const isNotesOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'notes';
|
||
|
||
return (
|
||
<li
|
||
className={cn(
|
||
'relative 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]',
|
||
// z-[80] when this row holds the open popover so the popover
|
||
// paints above the BottomNav (z-50) *and* above sibling rows.
|
||
(isPoOpen || isNotesOpen) ? 'z-[80]' : 'z-10'
|
||
)}
|
||
>
|
||
{/* Left: icon + description */}
|
||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||
{getItemIcon(item)}
|
||
<span className="text-sm text-[#26251e] truncate">
|
||
{item.description || 'Unknown item'}
|
||
</span>
|
||
{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>
|
||
|
||
{/* Right: qty + FileText + Collect/Problem */}
|
||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
|
||
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
|
||
|
||
{/* PO FileText button */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
measureRowPlacement(item.id, e.currentTarget);
|
||
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
|
||
}}
|
||
aria-label="Show PO details"
|
||
aria-expanded={isPoOpen}
|
||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
|
||
>
|
||
<FileText size={11} aria-hidden="true" />
|
||
</button>
|
||
{/* PO tooltip popover */}
|
||
{isPoOpen && (
|
||
<div
|
||
className={cn(
|
||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Purchase Order</p>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||
aria-label="Close"
|
||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||
>
|
||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||
</button>
|
||
</div>
|
||
{po ? (
|
||
<>
|
||
<dl className="space-y-1.5 text-xs">
|
||
<div className="flex justify-between">
|
||
<dt className="text-[rgba(38,37,30,0.55)]">PO Number</dt>
|
||
<dd className="text-[#26251e] font-medium font-mono">{po.po_number}</dd>
|
||
</div>
|
||
{po.status && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-[rgba(38,37,30,0.55)]">Status</dt>
|
||
<dd className="text-[#26251e] font-medium capitalize">{po.status}</dd>
|
||
</div>
|
||
)}
|
||
{po.total_amount != null && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-[rgba(38,37,30,0.55)]">Total</dt>
|
||
<dd className="text-[#26251e] font-medium">R{(po.total_amount / 100).toFixed(2)}</dd>
|
||
</div>
|
||
)}
|
||
{orderNumber && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
|
||
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||
className="mt-3 w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-[#6c5a99] text-white text-[12px] font-semibold active:bg-[#4a3d6b] transition-colors"
|
||
>
|
||
<ExternalLink size={13} aria-hidden="true" />
|
||
View PO & Document
|
||
</button>
|
||
</>
|
||
) : (
|
||
<div className="text-center py-2">
|
||
<p className="text-xs text-[rgba(38,37,30,0.55)]">No PO attached</p>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
|
||
className="mt-2 text-[11px] text-[#6c5a99] font-medium hover:underline"
|
||
>
|
||
View details
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Notes MessageSquare button — only when notes exist */}
|
||
{hasAnyNotes && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
measureRowPlacement(item.id, e.currentTarget);
|
||
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
|
||
}}
|
||
aria-label="Show notes"
|
||
aria-expanded={isNotesOpen}
|
||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
|
||
>
|
||
<MessageSquare size={11} aria-hidden="true" />
|
||
</button>
|
||
{/* Notes tooltip popover */}
|
||
{isNotesOpen && (
|
||
<div
|
||
className={cn(
|
||
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
|
||
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Buyer Notes</p>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
|
||
aria-label="Close"
|
||
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
|
||
>
|
||
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
|
||
</button>
|
||
</div>
|
||
{hasNotes ? (
|
||
<>
|
||
<p className="text-[11px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-lg p-2 selectable leading-snug">
|
||
{item.notes}
|
||
</p>
|
||
{hasProblemNotes && (
|
||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 mt-2 selectable leading-snug">
|
||
<span className="font-semibold">Problem: </span>{item.problem_notes}
|
||
</p>
|
||
)}
|
||
</>
|
||
) : hasProblemNotes ? (
|
||
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 selectable leading-snug">
|
||
{item.problem_notes}
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-[rgba(38,37,30,0.55)] text-center py-1">No notes</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Collect + Problem buttons */}
|
||
<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-[#8a1a3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
|
||
>
|
||
<AlertTriangle size={16} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export default function SupplierGroup({
|
||
supplier,
|
||
items,
|
||
color,
|
||
isExpanded,
|
||
viewMode,
|
||
onToggle,
|
||
onCollect,
|
||
onProblem,
|
||
onNavigate,
|
||
onShowDetails,
|
||
poGroups,
|
||
expandedPOs,
|
||
onTogglePO,
|
||
onBulkCollect,
|
||
onBulkProblem,
|
||
}: SupplierGroupProps) {
|
||
const coords = parseCoordinates(supplier.coordinates);
|
||
const hasCoords = coords !== null;
|
||
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
||
|
||
// Active tooltip: { itemId, type }
|
||
const [activeTooltip, setActiveTooltip] = useState<{ itemId: string; type: 'po' | 'notes' } | null>(null);
|
||
// Tracks whether a *detailed-mode* card inside this group has its
|
||
// own popover open — they live in CollectionItemCard state, so we
|
||
// learn about them via the onPopoverChange callback. Combined with
|
||
// activeTooltip above, this drives whether the section lifts to z-30
|
||
// to paint above the next supplier/area group.
|
||
const [childPopoverOpen, setChildPopoverOpen] = useState(false);
|
||
// Per-row popover placement — kept in a map at the top level rather
|
||
// than a hook inside the items.map() (which would violate the
|
||
// Rules of Hooks if the items array changed length between renders).
|
||
// Each entry is the last-measured direction for that row's popovers;
|
||
// default 'down' until the driver clicks the button for the first
|
||
// time and we measure the available space below it.
|
||
const [rowPlacements, setRowPlacements] = useState<Record<string, 'down' | 'up'>>({});
|
||
const listRef = useRef<HTMLUListElement>(null);
|
||
|
||
// Measure the trigger button on click and decide 'down' vs 'up' for
|
||
// its popover. Reserves ~80px for the BottomNav + safe area inset.
|
||
const measureRowPlacement = useCallback((itemId: string, btn: HTMLElement | null) => {
|
||
if (!btn || typeof window === 'undefined') return;
|
||
const rect = btn.getBoundingClientRect();
|
||
const available = window.innerHeight - rect.bottom - 80;
|
||
setRowPlacements((prev) => ({
|
||
...prev,
|
||
[itemId]: available < 240 ? 'up' : 'down',
|
||
}));
|
||
}, []);
|
||
|
||
// Close tooltip when clicking outside the list
|
||
useEffect(() => {
|
||
if (!activeTooltip) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (listRef.current && !listRef.current.contains(e.target as Node)) {
|
||
setActiveTooltip(null);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [activeTooltip]);
|
||
return (
|
||
<section
|
||
className={cn(
|
||
'relative bg-[#e6e5e0] rounded-2xl overflow-visible transition-all duration-200 border border-[rgba(38,37,30,0.1)]',
|
||
'border-l-[6px]',
|
||
color.border,
|
||
// z-[80] when *any* tooltip inside is open so the popover can
|
||
// paint above the BottomNav (z-50). The popover's own z-[60]
|
||
// is *inside* this section's stacking context, so without
|
||
// this lift the popover's effective page z-index would be
|
||
// only 80 (the section's z), still above the BottomNav.
|
||
// Combining both signals covers compact + detailed modes.
|
||
(activeTooltip || childPopoverOpen) ? 'z-[80]' : 'z-10'
|
||
)}
|
||
aria-label={`${supplier.suppliername} collection group`}
|
||
>
|
||
<button
|
||
onClick={onToggle}
|
||
aria-expanded={isExpanded}
|
||
aria-controls={`supplier-${supplier.id}-items`}
|
||
className="w-full text-left p-4 active:bg-[#ebeae5] transition-colors"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{/* Color tile — single accent, not a left border stripe */}
|
||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', color.light)} aria-hidden="true">
|
||
<MapPin size={16} className={color.text} />
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className="font-semibold text-[15px] text-[#26251e] truncate leading-snug">
|
||
{supplier.suppliername}
|
||
</h2>
|
||
{supplier.supplieraddress && (
|
||
<p className="text-[12px] text-[rgba(38,37,30,0.55)] 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="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>
|
||
|
||
{/* 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-[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>
|
||
|
||
{/* Expanded content */}
|
||
{isExpanded && (
|
||
<div
|
||
id={`supplier-${supplier.id}-items`}
|
||
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
||
>
|
||
{viewMode === 'compact' ? (
|
||
/* COMPACT MODE — flat list when no PO grouping, otherwise
|
||
nested PO sub-groups so the driver sees one invoice at a
|
||
time. The single <ul ref={listRef}> wrapper is preserved
|
||
in both cases so the click-outside-tooltip handler still
|
||
finds every row inside it. */
|
||
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
||
{poGroups && onTogglePO ? (
|
||
poGroups.flatMap((po, poIdx) => {
|
||
const poExpanded = expandedPOs?.has(po.poId) ?? true;
|
||
const poTotalQty = po.items.reduce(
|
||
(s, i) => s + (i.order_qty || i.quantity || 0),
|
||
0
|
||
);
|
||
const poLabel = `PO ${po.poNumber}`;
|
||
return [
|
||
<li
|
||
key={`po-header-${po.poId}`}
|
||
className="bg-[#ebeae5] border-y border-[rgba(38,37,30,0.06)]"
|
||
aria-label={`Invoice ${po.poNumber}`}
|
||
>
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<button
|
||
onClick={() => onTogglePO(po.poId)}
|
||
aria-expanded={poExpanded}
|
||
aria-controls={`po-${po.poId}-items`}
|
||
className="flex items-center gap-1.5 min-w-0 flex-1 text-left active:bg-[rgba(38,37,30,0.04)] rounded-md py-1 -mx-1 px-1"
|
||
>
|
||
{poExpanded ? (
|
||
<ChevronUp size={14} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
|
||
) : (
|
||
<ChevronDown size={14} className="text-[rgba(38,37,30,0.55)] flex-shrink-0" />
|
||
)}
|
||
<FileText size={12} className="text-[#6c5a99] flex-shrink-0" />
|
||
<span className="text-[12px] font-semibold text-[#26251e] font-mono truncate">
|
||
{po.poNumber}
|
||
</span>
|
||
<span className="text-[11px] text-[rgba(38,37,30,0.55)] flex-shrink-0">
|
||
· {po.items.length} {po.items.length === 1 ? 'item' : 'items'}{poTotalQty > 0 ? ` · ${poTotalQty} total` : ''}
|
||
</span>
|
||
</button>
|
||
{/* Per-PO bulk actions — only rendered when the
|
||
supplier actually has more than one PO (a
|
||
single-PO supplier renders the flat list
|
||
above, no point in redundant bulk buttons). */}
|
||
{poGroups.length > 1 && poExpanded && (
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
<button
|
||
onClick={() => onBulkCollect?.(poLabel, po.items)}
|
||
aria-label={`Mark all ${po.items.length} items on ${poLabel} collected`}
|
||
className="h-7 px-2 rounded-md bg-[#1f8a65] text-white text-[11px] font-semibold flex items-center gap-1 active:bg-[#125a3e] transition-colors touch-manipulation"
|
||
>
|
||
<CheckCheck size={11} aria-hidden="true" />
|
||
<span>All</span>
|
||
</button>
|
||
<button
|
||
onClick={() => onBulkProblem?.(poLabel, po.items)}
|
||
aria-label={`Flag all ${po.items.length} items on ${poLabel}`}
|
||
className="h-7 w-7 rounded-md bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
|
||
>
|
||
<AlertTriangle size={12} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</li>,
|
||
poExpanded
|
||
? po.items.map((item) => (
|
||
<CompactItemRow
|
||
key={item.id}
|
||
item={item}
|
||
activeTooltip={activeTooltip}
|
||
setActiveTooltip={setActiveTooltip}
|
||
rowPlacement={rowPlacements[item.id] ?? 'down'}
|
||
measureRowPlacement={measureRowPlacement}
|
||
onShowDetails={onShowDetails}
|
||
onCollect={onCollect}
|
||
onProblem={onProblem}
|
||
/>
|
||
))
|
||
: [],
|
||
// Spacer between PO groups so they read as distinct
|
||
// blocks rather than one continuous list.
|
||
poIdx < poGroups.length - 1 ? (
|
||
<li
|
||
key={`po-spacer-${po.poId}`}
|
||
className="h-2 bg-[#f2f1ed]"
|
||
aria-hidden="true"
|
||
/>
|
||
) : null,
|
||
];
|
||
})
|
||
) : (
|
||
items.map((item) => (
|
||
<CompactItemRow
|
||
key={item.id}
|
||
item={item}
|
||
activeTooltip={activeTooltip}
|
||
setActiveTooltip={setActiveTooltip}
|
||
rowPlacement={rowPlacements[item.id] ?? 'down'}
|
||
measureRowPlacement={measureRowPlacement}
|
||
onShowDetails={onShowDetails}
|
||
onCollect={onCollect}
|
||
onProblem={onProblem}
|
||
/>
|
||
))
|
||
)}
|
||
</ul>
|
||
) : (
|
||
/* 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}
|
||
onShowDetails={onShowDetails}
|
||
onPopoverChange={setChildPopoverOpen}
|
||
/>
|
||
))}
|
||
</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 min-h-[44px]',
|
||
color.dot
|
||
)}
|
||
>
|
||
<Navigation size={14} aria-hidden="true" />
|
||
Navigate to {supplier.suppliername}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
} |