WFDASHDRIVER-APP/components/CollectionItemCard.tsx
SJCoetz 85c63b336c feat: add PO/Notes popovers, supplier color sidebar, fix popover clipping
- Add ItemDetailModal showing PO document (view/download/share/email) + buyer notes
- Add FileText (PO) and MessageSquare (notes) buttons to compact item list with popovers
- Fix photo upload: append to order_items.order_item_attachements (PB file field)
- Add colored left sidebar stripe per supplier group for visual distinction
- Fix popover z-index so they appear above following cards (overflow-visible + z-50)
- Extend PurchaseOrder type with documents, email_payload, supplier
- Add order_item_attachements and expand.purchase_order to OrderItem
- onShowDetails prop wired through SupplierGroup and AreaGroup

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

509 lines
22 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;
}
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,
}: 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));
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-hidden'
: 'bg-[#e6e5e0] rounded-2xl card-press overflow-hidden border border-[rgba(38,37,30,0.1)]',
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-7 h-7 rounded-lg flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
>
<FileText size={13} aria-hidden="true" />
</button>
{/* PO tooltip popover */}
{showPoTooltip && (
<div
ref={poTooltipRef}
className="absolute right-0 z-50 mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
onClick={e => e.stopPropagation()}
>
{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-7 h-7 rounded-lg flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
>
<MessageSquare size={13} aria-hidden="true" />
</button>
{/* Notes tooltip popover */}
{showNotesTooltip && (
<div
ref={notesTooltipRef}
className="absolute right-0 z-50 mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
onClick={e => e.stopPropagation()}
>
<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>
);
}