Drivers can now switch the header toggle to 'Invoice' to see stops bucketed by PO within each supplier. Each PO sub-header is collapsible and shows the PO number + item count + total qty. Per-PO bulk actions (visible only when the supplier has more than one invoice): - Green 'All' button marks every item under that PO as collected in one tap (no per-item modal flow, expectedQty is auto-stamped). - Red flag button flags every item with a generic problem note. Implementation: - New 'po' groupMode in app/page.tsx with its own groupedByPO memo and expandedPOs Set state (auto-expanded on load, controlled by expandAll/collapseAll alongside supplier expansion). - SupplierGroup now accepts optional poGroups + handlers and renders PO sub-headers in compact mode. Each PO is rendered as a flat list of CompactItemRow subcomponents (extracted to share the row markup between flat-list and per-PO render paths). - bulkCollect / bulkProblem helpers in lib/pocketbase.ts loop the patch; per-row failures are logged and excluded from the count so one bad row doesn't poison the batch. Co-authored-by: Orca <help@stably.ai>
578 lines
No EOL
25 KiB
TypeScript
578 lines
No EOL
25 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { OrderItem, Supplier } 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',
|
||
// 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="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
|
||
Problem
|
||
</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>
|
||
);
|
||
} |