WFDASHDRIVER-APP/components/CollectionItemCard.tsx
SJCoetz 3259009690 feat: shrink PO/Notes buttons, lift popover stacking, responsive header
- PO (FileText) and Notes (MessageSquare) inline buttons: w-7 h-7 / w-8 h-8
  -> w-6 h-6 with size 11 icons and tighter radius, plus narrower right
  column gap so the smaller buttons don't sit in stale spacing.
- Popover z-index vs next group: add onPopoverChange callback to
  CollectionItemCard so SupplierGroup / AreaGroup can lift their
  section from z-10 to z-30 when *any* popover inside is open. Covers
  both compact (own popover) and detailed (card popover) modes. Active
  <li> in compact mode also lifts to z-30 so popover paints above
  sibling rows in the same list.
- Header categories on small screens: stats + toggle row now
  flex-wraps so chips don't overflow; added xs (480px) breakpoint
  and hid Supplier/Area labels below it to keep tiny phones clean.
  Status filter chip row already scrolls horizontally.

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

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

520 lines
23 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 { 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);
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)]',
(showNotesTooltip || showPoTooltip) && 'relative z-30',
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
onClick={(e) => { e.stopPropagation(); 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="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
>
{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
onClick={(e) => { e.stopPropagation(); 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="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
>
<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>
);
}