WFDASHDRIVER-APP/components/SupplierGroup.tsx
Dave b9a112881d feat: readable compact rows and a full-width PO sheet
- Item names wrap and get the full row width; the quantity sits beside
  the name and the PO / notes / Collect / Problem buttons move to their
  own line underneath, all 44px. Similar bolts truncated to
  "M22 x 150 H…" were impossible to tell apart on a phone.
- The PO and notes buttons open a full-width bottom sheet (portalled to
  <body>, above the BottomNav) instead of a 256px popover pinned to a
  mid-row button, which ran off the left edge of the screen.
- The PO sheet shows status, order and item count, with big Open PO PDF
  and Invoice photo buttons (PoActions size="lg").
- Dropped the popover up/down placement measuring and the
  click-outside listener; the sheet closes on backdrop, X or Escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:27:36 +02:00

555 lines
No EOL
24 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 } 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 BottomSheet from '@/components/BottomSheet';
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. Which sheet is open lives
// one level up so only one row's sheet can be open at a time.
interface CompactItemRowProps {
item: OrderItem;
/** Every item in this supplier group on the same PO (for the invoice photo). */
poItems: OrderItem[];
activeTooltip: { itemId: string; type: 'po' | 'notes' } | null;
setActiveTooltip: (t: { itemId: string; type: 'po' | 'notes' } | null) => void;
onShowDetails: (item: OrderItem) => void;
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onAddInvoicePage?: (items: OrderItem[], file: File) => Promise<void>;
}
function CompactItemRow({
item,
poItems,
activeTooltip,
setActiveTooltip,
onShowDetails,
onCollect,
onProblem,
onAddInvoicePage,
}: CompactItemRowProps) {
const expectedQty = item.order_qty || item.quantity || 0;
const po = item.expand?.purchase_order;
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';
const close = () => setActiveTooltip(null);
return (
<li
className={cn(
'relative 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]',
)}
>
{/* Line 1: icon + description + qty. The description gets the
full row width and wraps instead of truncating — similar bolts
("M22 x 150 H…") were impossible to tell apart on a phone, and
with the buttons beside it the name was squeezed to ~80px. */}
<div className="flex items-start gap-2.5">
<span className="mt-1 flex-shrink-0">{getItemIcon(item)}</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] leading-snug text-[#26251e] break-words">
{item.description || 'Unknown item'}
</p>
{item.problem_flag && (
<span
className={cn(
'inline-block mt-1 text-[11px] px-1.5 py-0.5 rounded-full font-medium',
isOutOfStock(item)
? 'bg-[#cf2d56] text-white'
: 'bg-[#fbeaef] text-[#8a1b3a]'
)}
>
{problemBadgeLabel(item)}
</span>
)}
</div>
<span className="text-[16px] font-semibold text-[#26251e] flex-shrink-0">{expectedQty}×</span>
</div>
{/* Line 2: PO + notes + Collect/Problem, right-aligned under the name */}
<div className="flex items-center justify-end gap-1.5 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
}}
aria-label="Show PO"
aria-expanded={isPoOpen}
className="w-11 h-11 rounded-lg flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation"
>
<FileText size={18} aria-hidden="true" />
</button>
{hasAnyNotes && (
<button
onClick={(e) => {
e.stopPropagation();
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
}}
aria-label="Show notes"
aria-expanded={isNotesOpen}
className="w-11 h-11 rounded-lg flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors touch-manipulation"
>
<MessageSquare size={18} aria-hidden="true" />
</button>
)}
<button
onClick={() => onCollect(item)}
aria-label={`Collect ${item.description || 'item'}`}
className="w-11 h-11 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation"
>
<svg width="18" height="18" 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-11 h-11 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
>
<AlertTriangle size={18} aria-hidden="true" />
</button>
</div>
{isPoOpen && (
<BottomSheet title="Purchase order" subtitle={po?.po_number || 'No PO attached'} onClose={close}>
<p className="text-[14px] text-[#26251e] break-words mb-3">{item.description}</p>
{po && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[14px] mb-4">
{po.status && (
<>
<dt className="text-[rgba(38,37,30,0.6)]">Status</dt>
<dd className="text-[#26251e] capitalize">{po.status}</dd>
</>
)}
{orderNumber && (
<>
<dt className="text-[rgba(38,37,30,0.6)]">Order</dt>
<dd className="text-[#26251e] break-words">{orderNumber}</dd>
</>
)}
<dt className="text-[rgba(38,37,30,0.6)]">On this PO</dt>
<dd className="text-[#26251e]">
{poItems.length} item{poItems.length === 1 ? '' : 's'}
</dd>
</dl>
)}
{po && (
<PoActions
purchaseOrder={po}
items={poItems}
size="lg"
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
<button
type="button"
onClick={() => { close(); onShowDetails(item); }}
className="mt-2 h-12 w-full rounded-xl text-[15px] font-semibold text-[#6c5a99] flex items-center justify-center gap-2 active:bg-[#efeaf7] touch-manipulation"
>
<ExternalLink size={16} aria-hidden="true" />
Item details
</button>
</BottomSheet>
)}
{isNotesOpen && (
<BottomSheet title="Buyer notes" subtitle={item.description || undefined} onClose={close}>
{hasNotes && (
<p className="text-[15px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl p-3 selectable leading-snug whitespace-pre-wrap break-words">
{item.notes}
</p>
)}
{hasProblemNotes && (
<p className={cn(
'text-[15px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-xl p-3 selectable leading-snug whitespace-pre-wrap break-words',
hasNotes && 'mt-2',
)}>
<span className="font-semibold">Problem: </span>{item.problem_notes}
</p>
)}
</BottomSheet>
)}
</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);
const listRef = useRef<HTMLUListElement>(null);
// Row sheets close themselves (backdrop tap, Escape, the X button),
// so there is no click-outside listener any more — it would have
// fired on taps inside the sheet, which is portalled out of this list.
const poItemsFor = (item: OrderItem) => {
const id = item.expand?.purchase_order?.id;
return id ? allItems.filter((i) => i.expand?.purchase_order?.id === id) : [item];
};
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}
poItems={poItemsFor(item)}
onAddInvoicePage={onAddInvoicePage}
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}
poItems={poItemsFor(item)}
onAddInvoicePage={onAddInvoicePage}
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>
);
}