WFDASHDRIVER-APP/components/CollectionItemCard.tsx
SJCoetz 105f169e45 feat: popover stays above footer, partial collections must be justified
Two improvements to the collection flow:

1. Popover clipping under BottomNav (last item in list)
   - Root cause: popovers were z-[60] *inside* a parent stacking
     context at z-30, so their effective page z-index was 30 — below
     BottomNav's z-50. Even if the z-index were right, the popover
     extends below the trigger button and could fall below the
     viewport on the last item in a long list.
   - Add lib/hooks.ts with usePopoverPlacement: measures the
     trigger's bottom edge and flips popover direction to "up" when
     there isn't ~240px of room below (reserving 80px for BottomNav
     + safe-area inset).
   - Bump the article/section/li stacking context from z-30 to z-[80]
     when a popover is open so the popover paints above BottomNav
     (z-50). Covers both compact-mode SupplierGroup rows and
     detailed-mode CollectionItemCard via the existing
     onPopoverChange callback.
   - For SupplierGroup: hooks live at the top level (record keyed by
     item.id) to avoid the Rules of Hooks trap of declaring hooks
     inside items.map().

2. Partial collections must be justified
   - In CollectModal, when received qty < expected, require EITHER a
     reason (dropdown: out of stock / wrong item / quality issue /
     damaged / wrong qty packed / other) OR a photo before Confirm
     is enabled. Prevents silent partial submissions with no audit
     trail.
   - The picked reason flows into the auto-generated problem note
     so the operator sees "[Reason] driver note" in PB.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-02 22:58:08 +02:00

556 lines
25 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 } from 'react';
import { OrderItem } from '@/lib/types';
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
import { usePopoverPlacement } from '@/lib/hooks';
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
import {
Check,
AlertTriangle,
Navigation,
ChevronDown,
ChevronUp,
Package,
Wrench,
Hammer,
ShoppingCart,
AlertCircle,
Clock,
MapPin,
FileText,
MessageSquare,
ExternalLink,
} from 'lucide-react';
interface CollectionItemProps {
item: OrderItem;
supplierColor?: { dot: string; light: string; text: string; ring: string };
compact?: boolean;
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
// Opens the ItemDetailModal showing PO document + buyer notes
onShowDetails: (item: OrderItem) => void;
// Notifies the parent that a popover has opened/closed so it can
// lift its own stacking layer — the card's z-30 alone can't escape
// the section's stacking context, so the section needs to know.
onPopoverChange?: (open: boolean) => void;
}
function getItemType(item: OrderItem): {
type: string;
icon: React.ReactNode;
color: string;
bgColor: string;
label: string;
} {
if (item.problem_flag) {
return {
type: 'problem',
icon: <AlertTriangle size={16} />,
color: 'text-[#b3224a]',
bgColor: 'bg-[#fbeaef]',
label: 'Problem',
};
}
const orderNumber = item.expand?.order?.order_number || '';
const poNumber = item.expand?.purchase_order?.po_number || '';
if (item.is_stock_item) {
return {
type: 'stock',
icon: <Package size={16} />,
color: 'text-[#4a3d6b]',
bgColor: 'bg-[#d4cae3]',
label: 'Stock',
};
}
if (orderNumber.startsWith('RPR') || poNumber.startsWith('RPR')) {
return {
type: 'repair',
icon: <Wrench size={16} />,
color: 'text-[#8a5a02]',
bgColor: 'bg-[#f6e3a4]',
label: 'Repair',
};
}
if (orderNumber.startsWith('BRK') || poNumber.startsWith('BRK')) {
return {
type: 'breakdown',
icon: <Hammer size={16} />,
color: 'text-[#8a5a02]',
bgColor: 'bg-[#f6e3a4]',
label: 'Breakdown',
};
}
return {
type: 'normal',
icon: <ShoppingCart size={16} />,
color: 'text-[#26251e]',
bgColor: 'bg-[#ebeae5]',
label: 'Order',
};
}
// Closes tooltip when clicking outside
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClose: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, onClose]);
}
export default function CollectionItemCard({
item,
supplierColor,
compact,
onCollect,
onProblem,
onNavigate,
onShowDetails,
onPopoverChange,
}: CollectionItemProps) {
const [expanded, setExpanded] = useState(false);
const [showNotesTooltip, setShowNotesTooltip] = useState(false);
const [showPoTooltip, setShowPoTooltip] = useState(false);
const notesTooltipRef = useRef<HTMLDivElement>(null);
const poTooltipRef = useRef<HTMLDivElement>(null);
// Button refs feed the placement hook so the popover flips above
// the button when there's not enough room below it (e.g. on the
// last item in a long list, where the default top-full direction
// would push the popover under the BottomNav).
const poButtonRef = useRef<HTMLButtonElement>(null);
const notesButtonRef = useRef<HTMLButtonElement>(null);
const { placement: poPlacement, measure: measurePoPlacement } = usePopoverPlacement(poButtonRef);
const { placement: notesPlacement, measure: measureNotesPlacement } = usePopoverPlacement(notesButtonRef);
useClickOutside(notesTooltipRef, () => setShowNotesTooltip(false));
useClickOutside(poTooltipRef, () => setShowPoTooltip(false));
// Bubble the open/close state up so the wrapping supplier/area
// group can lift itself above the *next* group in the list.
const popoverOpen = showNotesTooltip || showPoTooltip;
useEffect(() => {
onPopoverChange?.(popoverOpen);
}, [popoverOpen, onPopoverChange]);
const supplier = item.expand?.supplier;
const itemType = getItemType(item);
const coords = parseCoordinates(supplier?.coordinates);
const hasCoords = coords !== null;
const description = getItemDisplayDescription(item);
const expectedQty = getItemDisplayQty(item);
const po = item.expand?.purchase_order;
const order = item.expand?.order;
const poNumber = po?.po_number;
const orderNumber = order?.order_number;
const hasNotes = Boolean(item.notes?.trim());
const hasProblemNotes = Boolean(item.problem_notes?.trim());
const hasAnyNotes = hasNotes || hasProblemNotes;
return (
<article
className={cn(
compact
? 'bg-[#ebeae5] rounded-xl overflow-visible'
: 'bg-[#e6e5e0] rounded-2xl card-press overflow-visible border border-[rgba(38,37,30,0.1)]',
// z-[80] (above the BottomNav's z-50) when a popover is open,
// so the popover can paint above the bottom chrome. The
// popover's own z-[60] is *inside* this article's stacking
// context, so without this lift the popover's effective page
// z-index is only 80 — wait, this is the article's z. The
// popover stacks at this z in the page context.
(showNotesTooltip || showPoTooltip) && 'relative z-[80]',
item.problem_flag && 'ring-1 ring-[#cf2d56]'
)}
>
{/* Header */}
<button
className="w-full text-left p-4 active:bg-[rgba(38,37,30,0.04)] transition-colors"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={`item-${item.id}-details`}
>
<div className="flex items-start gap-3">
{/* Type icon */}
<div className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5',
itemType.bgColor
)} aria-hidden="true">
<span className={itemType.color}>{itemType.icon}</span>
</div>
{/* Main content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h3 className="font-semibold text-[15px] text-[#26251e] truncate leading-snug flex-1">
{description}
</h3>
</div>
{/* PO and Order numbers — always visible in compact mode */}
{compact && (poNumber || orderNumber) && (
<div className="flex items-center gap-2 mb-0.5 -mt-0.5">
{poNumber && (
<span className="text-[10px] text-[rgba(38,37,30,0.5)] font-mono">
PO: {poNumber}
</span>
)}
{poNumber && orderNumber && (
<span className="text-[10px] text-[rgba(38,37,30,0.3)]" aria-hidden="true">·</span>
)}
{orderNumber && (
<span className="text-[10px] text-[rgba(38,37,30,0.5)]">
Order: {orderNumber}
</span>
)}
</div>
)}
{/* If the customer-facing description differs from the
supplier-facing one we just rendered, surface the
customer one as a smaller sub-line so the driver can
spot a discrepancy on pickup. */}
{item.description && item.po_description && item.description.trim() !== item.po_description.trim() && (
<p className="text-[11px] text-[rgba(38,37,30,0.55)] truncate -mt-0.5 mb-0.5">
customer line: {item.description}
</p>
)}
<div className="flex items-center gap-1.5 flex-wrap">
<span className={cn(
'text-[10px] px-2 py-0.5 rounded-full font-semibold uppercase tracking-wide',
itemType.type === 'problem' && 'bg-[#f3c4d2] text-[#8a1b3a]',
itemType.type === 'breakdown' && 'bg-[#f6e3a4] text-[#8a5a02]',
itemType.type === 'repair' && 'bg-[#f6e3a4] text-[#8a5a02]',
itemType.type === 'stock' && 'bg-[#d4cae3] text-[#4a3d6b]',
itemType.type === 'normal' && 'bg-[#ebeae5] text-[#26251e]'
)}>
{itemType.label}
</span>
<span className="text-xs text-[rgba(38,37,30,0.6)]">Qty: {expectedQty}</span>
{item.sku && (
<span className="text-[10px] text-[rgba(38,37,30,0.55)] font-mono">#{item.sku}</span>
)}
{item.assigned_at && (
<span className="text-[10px] text-[rgba(38,37,30,0.45)] flex items-center gap-0.5">
<Clock size={9} aria-hidden="true" />
assigned {formatDate(item.assigned_at)}
</span>
)}
</div>
{supplier?.suppliername && !compact && (
<div className="flex items-center gap-1 mt-1.5">
<MapPin size={12} className="text-[rgba(38,37,30,0.4)] flex-shrink-0" aria-hidden="true" />
<span className="text-xs text-[rgba(38,37,30,0.55)] truncate">{supplier.suppliername}</span>
</div>
)}
</div>
{/* Right side — tooltip buttons always visible in compact mode */}
{compact ? (
<div className="flex flex-col items-center gap-1.5 flex-shrink-0">
{/* Price + chevron row */}
<div className="flex items-center gap-2">
{item.selling_price && expectedQty > 0 && (
<span className="text-xs font-semibold text-[#125a3e]">
{formatCurrency(item.selling_price * expectedQty)}
</span>
)}
{expanded ? (
<ChevronUp size={18} className="text-[rgba(38,37,30,0.55)]" aria-hidden="true" />
) : (
<ChevronDown size={18} className="text-[rgba(38,37,30,0.55)]" aria-hidden="true" />
)}
</div>
{/* Always-visible tooltip buttons — shown as a row of 2 */}
<div className="flex items-center gap-1">
{/* PO tooltip button */}
<div className="relative">
<button
ref={poButtonRef}
onClick={(e) => {
e.stopPropagation();
measurePoPlacement();
setShowPoTooltip(v => !v);
setShowNotesTooltip(false);
}}
aria-label="Show PO details"
aria-expanded={showPoTooltip}
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 */}
{showPoTooltip && (
<div
ref={poTooltipRef}
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',
// Flip above the button when there's not enough
// room below — keeps the popover out from under
// the BottomNav on the last item in a long list.
poPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
)}
>
{po ? (
<>
<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(); setShowPoTooltip(false); }}
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>
<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(); setShowPoTooltip(false); 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(); setShowPoTooltip(false); onShowDetails(item); }}
className="mt-2 text-[11px] text-[#6c5a99] font-medium hover:underline"
>
View details
</button>
</div>
)}
</div>
)}
</div>
{/* Notes tooltip button */}
{hasAnyNotes && (
<div className="relative">
<button
ref={notesButtonRef}
onClick={(e) => {
e.stopPropagation();
measureNotesPlacement();
setShowNotesTooltip(v => !v);
setShowPoTooltip(false);
}}
aria-label="Show notes"
aria-expanded={showNotesTooltip}
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 */}
{showNotesTooltip && (
<div
ref={notesTooltipRef}
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',
notesPlacement === '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(); setShowNotesTooltip(false); }}
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>
)}
</div>
</div>
) : (
/* Full/non-compact right side */
<div className="flex items-center gap-2 flex-shrink-0">
{item.selling_price && expectedQty > 0 && (
<span className="text-xs font-semibold text-[#125a3e]">
{formatCurrency(item.selling_price * expectedQty)}
</span>
)}
{expanded ? (
<ChevronUp size={18} className="text-[rgba(38,37,30,0.55)]" aria-hidden="true" />
) : (
<ChevronDown size={18} className="text-[rgba(38,37,30,0.55)]" aria-hidden="true" />
)}
</div>
)}
</div>
</button>
{/* Expanded Details */}
{expanded && (
<div
id={`item-${item.id}-details`}
className="px-4 pb-4 border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
>
<div className="pt-3 space-y-3">
{/* Notes */}
{item.notes && (
<div className="bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl p-3">
<p className="text-xs text-[#8a5a02] flex items-start gap-2">
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="selectable">{item.notes}</span>
</p>
</div>
)}
{item.problem_notes && (
<div className="bg-[#fbeaef] border border-[#f3c4d2] rounded-xl p-3">
<p className="text-xs text-[#8a1b3a] flex items-start gap-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="selectable">{item.problem_notes}</span>
</p>
</div>
)}
{/* Details grid */}
<dl className="grid grid-cols-2 gap-2 text-xs">
{orderNumber && (
<div className="bg-[#ebeae5] rounded-lg p-2">
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
</div>
)}
{poNumber && (
<div className="bg-[#ebeae5] rounded-lg p-2">
<dt className="text-[rgba(38,37,30,0.55)]">PO</dt>
<dd className="text-[#26251e] font-medium">{poNumber}</dd>
</div>
)}
{supplier?.supplieraddress && (
<div className="bg-[#ebeae5] rounded-lg p-2 col-span-2">
<dt className="text-[rgba(38,37,30,0.55)]">Address</dt>
<dd className="text-[#26251e] font-medium selectable">{supplier.supplieraddress}</dd>
</div>
)}
</dl>
{/* Action Buttons */}
<div className="space-y-2 pt-1">
{/* Row 1 — core workflow */}
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => onCollect(item)}
aria-label={`Mark ${description} as collected`}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[#1f8a65] text-white active:bg-[#125a3e] transition-colors touch-manipulation press-feedback-sm min-h-[56px]"
>
<Check size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Collect</span>
</button>
<button
onClick={() => onProblem(item)}
aria-label={`Report problem with ${description}`}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[#cf2d56] text-white active:bg-[#8a1b3a] transition-colors touch-manipulation press-feedback-sm min-h-[56px]"
>
<AlertTriangle size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Problem</span>
</button>
{hasCoords && coords && supplier ? (
<button
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
aria-label={`Navigate to ${supplier.suppliername}`}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[#f54e00] text-white active:bg-[#d44200] transition-colors touch-manipulation press-feedback-sm min-h-[56px]"
>
<Navigation size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Navigate</span>
</button>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[rgba(38,37,30,0.06)] text-[rgba(38,37,30,0.4)] min-h-[56px]" aria-label="No GPS coordinates available">
<Navigation size={18} aria-hidden="true" />
<span className="text-xs font-medium">No GPS</span>
</div>
)}
</div>
{/* Row 2 — PO document and buyer notes */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => onShowDetails(item)}
aria-label={`View PO document for ${description}`}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation press-feedback-sm min-h-[56px]"
>
<FileText size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">View PO</span>
</button>
<button
onClick={() => onShowDetails(item)}
aria-label={`View buyer notes for ${description}`}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors touch-manipulation press-feedback-sm min-h-[56px]"
>
<MessageSquare size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Buyer Notes</span>
</button>
</div>
</div>
</div>
</div>
)}
</article>
);
}