WFDASHDRIVER-APP/components/SupplierGroup.tsx
Dave de16082ff7 feat: one invoice photo per PO, one-tap PO PDF, save lock, sharper photos
- Confirm Collection locks while saving ("Saving…") and the page drops a
  second collect for an item already in flight. Repeat taps on slow data
  re-ran the save and re-uploaded the photo (7 identical copies on one
  item on 2026-10-16).
- PO headers (and a single-PO supplier's list) get an Invoice button:
  photograph the supplier invoice once per PO, then "add pg 2", "pg 3".
  Pages go onto one customer line of the PO as invoice-<PO>-p<N>.jpg;
  WFDASH Collection Confirm already gathers photos per PO card. The page
  count is also kept on the device so it survives lines dropping off.
- PO button opens the PO PDF in one tap (shared buildPoFileUrl in
  lib/poDocs.ts, also used by the item detail modal).
- Collect modal says whether the PO's invoice is already uploaded, so
  the per-item photo is only for problems with the item.
- Photos now upload at 2000px / q0.8 (was 1200px / q0.7) so small print
  on invoices stays readable for the AI checks.
- Supplier header counts and detailed view now include items in PO
  grouping mode (they showed 0 items before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:43:35 +02:00

629 lines
No EOL
28 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, 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 PoActions from '@/components/PoActions';
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;
// Uploads one invoice photo page for a PO (see components/PoActions).
// Without it the PO headers only offer the PDF button.
onAddInvoicePage?: (items: OrderItem[], file: File) => Promise<void>;
}
const noUpload = async () => {};
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 &amp; 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,
onAddInvoicePage,
}: SupplierGroupProps) {
const coords = parseCoordinates(supplier.coordinates);
const hasCoords = coords !== null;
// In PO mode the page passes the items inside poGroups and leaves
// `items` empty, so count (and list, in detailed view) from both.
const allItems = poGroups ? poGroups.flatMap((po) => po.items) : items;
const totalQty = allItems.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
const showPoHeaders = Boolean(poGroups && onTogglePO) && viewMode === 'compact';
// A supplier with a single PO gets the PO buttons once, at the top of
// its list, whenever there is no PO header to carry them.
const poIds = new Set(allItems.map((i) => i.expand?.purchase_order?.id ?? ''));
const singlePo =
!showPoHeaders && poIds.size === 1 && !poIds.has('')
? allItems[0].expand?.purchase_order ?? null
: null;
// 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]">
{allItems.length} item{allItems.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">
{allItems.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(allItems.length, 3) - 1 && ','}
</span>
))}
{allItems.length > 3 && (
<span className="text-[10px] text-[rgba(38,37,30,0.4)] font-medium">
+{allItems.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"
>
{singlePo && (
<div className="flex items-center gap-2 px-4 py-2 bg-[#ebeae5] border-b border-[rgba(38,37,30,0.06)]">
<FileText size={12} className="text-[#6c5a99] flex-shrink-0" aria-hidden="true" />
<span className="text-[12px] font-semibold text-[#26251e] font-mono truncate flex-1 min-w-0">
{singlePo.po_number}
</span>
<PoActions
purchaseOrder={singlePo}
items={allItems}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
</div>
)}
{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">
{showPoHeaders && 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>
{po.poId !== '__no_po__' && (
<PoActions
purchaseOrder={po.items[0]?.expand?.purchase_order ?? null}
items={po.items}
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
{/* 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,
];
})
) : (
allItems.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">
{allItems.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>
);
}