- 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>
509 lines
22 KiB
TypeScript
509 lines
22 KiB
TypeScript
'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 & 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>
|
||
);
|
||
}
|