WFDASHDRIVER-APP/components/SupplierGroup.tsx
Dave 286b86162b feat(problems): say what kind of problem, and flag out-of-stock rows
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.
2026-10-15 08:24:58 +02:00

589 lines
No EOL
26 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 {
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 &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,
}: 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>
);
}