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>
This commit is contained in:
SJCoetz 2026-08-02 21:38:38 +02:00
parent 2a7720cd1f
commit 85c63b336c
10 changed files with 875 additions and 213 deletions

View file

@ -10,6 +10,7 @@ import SupplierGroup from '@/components/SupplierGroup';
import AreaGroup from '@/components/AreaGroup';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import ItemDetailModal from '@/components/ItemDetailModal';
import {
getMyAssignedItems,
getPoolItems,
@ -40,16 +41,16 @@ import {
// tile (not a saturated left-border stripe). Each group gets a
// distinct hue but all read as part of the same family.
const SUPPLIER_COLORS = [
{ dot: 'bg-[#f54e00]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#f54e00]/30' }, // orange (signature)
{ dot: 'bg-[#18714f]', light: 'bg-[#e6f1ec]', text: 'text-[#125a3e]', ring: 'ring-[#18714f]/30' }, // emerald
{ dot: 'bg-[#6c5a99]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // violet
{ dot: 'bg-[#cf2d56]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // rose
{ dot: 'bg-[#8a5a02]', light: 'bg-[#fcf3d8]', text: 'text-[#8a5a02]', ring: 'ring-[#d49103]/30' }, // amber
{ dot: 'bg-[#26251e]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#26251e]/30' }, // ink
{ dot: 'bg-[#6b6760]', light: 'bg-[#ebeae5]', text: 'text-[#4a4742]', ring: 'ring-[#6b6760]/30' }, // warm gray
{ dot: 'bg-[#0d9488]', light: 'bg-[#e6f5f3]', text: 'text-[#0a7a70]', ring: 'ring-[#0d9488]/30' }, // teal
{ dot: 'bg-[#8a1b3a]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // crimson
{ dot: 'bg-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
{ dot: 'bg-[#f54e00]', border: 'border-l-[#f54e00]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#f54e00]/30' }, // orange (signature)
{ dot: 'bg-[#18714f]', border: 'border-l-[#18714f]', light: 'bg-[#e6f1ec]', text: 'text-[#125a3e]', ring: 'ring-[#18714f]/30' }, // emerald
{ dot: 'bg-[#6c5a99]', border: 'border-l-[#6c5a99]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // violet
{ dot: 'bg-[#cf2d56]', border: 'border-l-[#cf2d56]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // rose
{ dot: 'bg-[#8a5a02]', border: 'border-l-[#8a5a02]', light: 'bg-[#fcf3d8]', text: 'text-[#8a5a02]', ring: 'ring-[#d49103]/30' }, // amber
{ dot: 'bg-[#26251e]', border: 'border-l-[#26251e]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#26251e]/30' }, // ink
{ dot: 'bg-[#6b6760]', border: 'border-l-[#6b6760]', light: 'bg-[#ebeae5]', text: 'text-[#4a4742]', ring: 'ring-[#6b6760]/30' }, // warm gray
{ dot: 'bg-[#0d9488]', border: 'border-l-[#0d9488]', light: 'bg-[#e6f5f3]', text: 'text-[#0a7a70]', ring: 'ring-[#0d9488]/30' }, // teal
{ dot: 'bg-[#8a1b3a]', border: 'border-l-[#8a1b3a]', light: 'bg-[#fbeaef]', text: 'text-[#8a1b3a]', ring: 'ring-[#cf2d56]/30' }, // crimson
{ dot: 'bg-[#4a3d6b]', border: 'border-l-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
];
export default function CollectionsPage() {
@ -75,6 +76,7 @@ export default function CollectionsPage() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
const [showCollectModal, setShowCollectModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showProblemModal, setShowProblemModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
@ -279,6 +281,11 @@ const loadItems = useCallback(async (showLoading = true) => {
window.open(url, '_blank');
};
const handleShowDetails = (item: OrderItem) => {
setSelectedItem(item);
setShowDetailModal(true);
};
const toggleSupplier = (supplierId: string) => {
setExpandedSuppliers(prev => {
const next = new Set(prev);
@ -600,6 +607,7 @@ const loadItems = useCallback(async (showLoading = true) => {
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
/>
);
})
@ -622,6 +630,7 @@ const loadItems = useCallback(async (showLoading = true) => {
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
onNavigate={handleNavigate}
onShowDetails={handleShowDetails}
/>
);
})
@ -643,6 +652,12 @@ const loadItems = useCallback(async (showLoading = true) => {
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
onConfirm={handleProblem}
/>
<ItemDetailModal
item={selectedItem!}
purchaseOrder={selectedItem?.expand?.purchase_order ?? null}
isOpen={showDetailModal}
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
/>
{/* Toast */}
{toast && (

View file

@ -35,6 +35,7 @@ interface AreaGroupProps {
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
onShowDetails: (item: OrderItem) => void;
}
function getItemIcon(item: OrderItem) {
@ -45,7 +46,6 @@ function getItemIcon(item: OrderItem) {
if (item.is_stock_item) return <Package size={12} className="text-violet-500" />;
return <ShoppingCart size={12} className="text-gray-400" />;
}
export default function AreaGroup({
clusterId,
label,
@ -58,6 +58,7 @@ export default function AreaGroup({
onCollect,
onProblem,
onNavigate,
onShowDetails,
}: AreaGroupProps) {
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
@ -216,6 +217,7 @@ export default function AreaGroup({
onCollect={onCollect}
onProblem={onProblem}
onNavigate={onNavigate}
onShowDetails={onShowDetails}
/>
))}
</div>

View file

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
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';
@ -17,6 +17,9 @@ import {
AlertCircle,
Clock,
MapPin,
FileText,
MessageSquare,
ExternalLink,
} from 'lucide-react';
interface CollectionItemProps {
@ -26,6 +29,8 @@ interface CollectionItemProps {
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): {
@ -46,18 +51,19 @@ function getItemType(item: OrderItem): {
}
const orderNumber = item.expand?.order?.order_number || '';
const poNumber = item.expand?.purchase_order?.po_number || '';
if (orderNumber.toLowerCase().includes('breakdown')) {
if (item.is_stock_item) {
return {
type: 'breakdown',
icon: <Hammer size={16} />,
color: 'text-[#8a5a02]',
bgColor: 'bg-[#fcf3d8]',
label: 'Breakdown',
type: 'stock',
icon: <Package size={16} />,
color: 'text-[#4a3d6b]',
bgColor: 'bg-[#d4cae3]',
label: 'Stock',
};
}
if (orderNumber.toLowerCase().includes('repair')) {
if (orderNumber.startsWith('RPR') || poNumber.startsWith('RPR')) {
return {
type: 'repair',
icon: <Wrench size={16} />,
@ -67,13 +73,13 @@ function getItemType(item: OrderItem): {
};
}
if (item.is_stock_item) {
if (orderNumber.startsWith('BRK') || poNumber.startsWith('BRK')) {
return {
type: 'stock',
icon: <Package size={16} />,
color: 'text-[#4a3d6b]',
bgColor: 'bg-[#ece8f3]',
label: 'Stock',
type: 'breakdown',
icon: <Hammer size={16} />,
color: 'text-[#8a5a02]',
bgColor: 'bg-[#f6e3a4]',
label: 'Breakdown',
};
}
@ -86,6 +92,19 @@ function getItemType(item: OrderItem): {
};
}
// 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,
@ -93,19 +112,31 @@ export default function CollectionItemCard({
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;
// Per the project brief: prefer the supplier-facing description on the
// pickup screen; fall back to the customer-facing description.
const description = getItemDisplayDescription(item);
// Per the brief: prefer order_qty; falls back to quantity.
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(
@ -131,13 +162,33 @@ export default function CollectionItemCard({
<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">
<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
@ -171,7 +222,7 @@ export default function CollectionItemCard({
)}
</div>
{supplier?.suppliername && (
{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>
@ -179,18 +230,167 @@ export default function CollectionItemCard({
)}
</div>
<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>
{/* 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>
@ -222,16 +422,16 @@ export default function CollectionItemCard({
{/* Details grid */}
<dl className="grid grid-cols-2 gap-2 text-xs">
{item.expand?.order?.order_number && (
{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">{item.expand.order.order_number}</dd>
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
</div>
)}
{item.expand?.purchase_order?.po_number && (
{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">{item.expand.purchase_order.po_number}</dd>
<dd className="text-[#26251e] font-medium">{poNumber}</dd>
</div>
)}
{supplier?.supplieraddress && (
@ -241,46 +441,69 @@ export default function CollectionItemCard({
</div>
)}
</dl>
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2 pt-1">
<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 ? (
<div className="space-y-2 pt-1">
{/* Row 1 — core workflow */}
<div className="grid grid-cols-3 gap-2">
<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]"
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]"
>
<Navigation size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Navigate</span>
<Check size={18} strokeWidth={2.5} aria-hidden="true" />
<span className="text-xs font-semibold">Collect</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>
)}
<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>
);
}
}

View file

@ -0,0 +1,308 @@
'use client';
import { OrderItem, PurchaseOrder } from '@/lib/types';
import { pb } from '@/lib/pocketbase';
import {
X,
FileText,
Share2,
Mail,
Check,
AlertCircle,
Copy,
ExternalLink,
} from 'lucide-react';
import { useState } from 'react';
interface ItemDetailModalProps {
item: OrderItem;
purchaseOrder: PurchaseOrder | null;
isOpen: boolean;
onClose: () => void;
}
// PocketBase's pb.files.getUrl expects a record object with id and
// collectionId. We satisfy the contract with a typed inline object rather
// than a broader `any` cast.
function buildPoFileUrl(po: PurchaseOrder): string | null {
if (!po.documents?.length) return null;
const record = {
id: po.id,
collectionId: (po as unknown as { collectionId: string }).collectionId ?? '',
};
return pb.files.getUrl(record, po.documents[0]);
}
function statusColor(status: string): string {
switch (status.toLowerCase()) {
case 'sent':
case 'approved':
return 'bg-[#e6f1ec] text-[#125a3e]';
case 'pending':
return 'bg-[#fcf3d8] text-[#8a5a02]';
case 'cancelled':
return 'bg-[#fbeaef] text-[#8a1b3a]';
default:
return 'bg-[#ebeae5] text-[#26251e]';
}
}
export default function ItemDetailModal({
item,
purchaseOrder,
isOpen,
onClose,
}: ItemDetailModalProps) {
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
if (!isOpen) return null;
const poUrl = purchaseOrder ? buildPoFileUrl(purchaseOrder) : null;
const supplier = item.expand?.supplier;
const hasNotes = Boolean(item.notes?.trim());
const handleShare = async () => {
if (!poUrl || !purchaseOrder) return;
setSharing(true);
try {
if (navigator.share) {
await navigator.share({
title: `Purchase Order ${purchaseOrder.po_number}`,
text: `Purchase Order ${purchaseOrder.po_number} for ${supplier?.suppliername ?? 'your order'}`,
url: poUrl,
});
} else {
await navigator.clipboard.writeText(poUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
} finally {
setSharing(false);
}
};
const handleEmail = () => {
if (!purchaseOrder?.email_payload) return;
const { subject, bodyText } = purchaseOrder.email_payload;
const bodyWithLink = poUrl ? `${bodyText}\n\nPDF Download: ${poUrl}` : bodyText;
const mailto = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyWithLink)}`;
window.location.href = mailto;
};
const handleCopyLink = async () => {
if (!poUrl) return;
try {
await navigator.clipboard.writeText(poUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
} catch {
// clipboard may be blocked
}
};
return (
<div
className="fixed inset-0 z-[80] flex items-end sm:items-center justify-center"
role="dialog"
aria-modal="true"
aria-label="Item details"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Sheet */}
<div className="relative w-full max-w-lg bg-[#ebeae5] rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90dvh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[rgba(38,37,30,0.1)]">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#1f8a65] flex items-center justify-center">
<FileText size={18} className="text-white" aria-hidden="true" />
</div>
<div>
<h2 className="text-[15px] font-semibold text-[#26251e] leading-tight">
{purchaseOrder?.po_number ?? 'No PO'}
</h2>
{supplier && (
<p className="text-[11px] text-[rgba(38,37,30,0.55)] leading-tight mt-0.5">
{supplier.suppliername}
</p>
)}
</div>
</div>
<button
onClick={onClose}
aria-label="Close details"
className="w-9 h-9 rounded-xl flex items-center justify-center active:bg-[rgba(38,37,30,0.08)] transition-colors"
>
<X size={20} className="text-[#26251e]" aria-hidden="true" />
</button>
</div>
{/* Scrollable body */}
<div className="overflow-y-auto flex-1 p-5 space-y-5">
{/* PO summary grid */}
<div className="grid grid-cols-2 gap-3">
{purchaseOrder?.status && (
<div className="bg-[#f2f1ed] rounded-xl p-3">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
Status
</p>
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-lg ${statusColor(
purchaseOrder.status
)}`}
>
{purchaseOrder.status}
</span>
</div>
)}
{purchaseOrder?.total_amount != null && (
<div className="bg-[#f2f1ed] rounded-xl p-3">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
PO Total
</p>
<p className="text-[14px] font-semibold text-[#26251e]">
R{(purchaseOrder.total_amount / 100).toFixed(2)}
</p>
</div>
)}
{item.expand?.order?.order_number && (
<div className="bg-[#f2f1ed] rounded-xl p-3">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
Order Ref
</p>
<p className="text-[13px] font-medium text-[#26251e] leading-tight">
{item.expand.order.order_number}
</p>
</div>
)}
{supplier?.supplieraddress && (
<div className="bg-[#f2f1ed] rounded-xl p-3 col-span-2">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
Supplier Address
</p>
<p className="text-[13px] font-medium text-[#26251e] leading-snug selectable">
{supplier.supplieraddress}
</p>
</div>
)}
</div>
{/* PO Document actions */}
{poUrl ? (
<div className="space-y-3">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
Attached Document
</p>
<div className="flex items-center gap-2">
<a
href={poUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-[#26251e] text-white active:bg-[#3a3832] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
>
<ExternalLink size={15} aria-hidden="true" />
View / Download PDF
</a>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleShare}
disabled={sharing}
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#1f8a65] text-white active:bg-[#125a3e] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold disabled:opacity-60"
>
{copied ? (
<>
<Check size={15} aria-hidden="true" />
Copied!
</>
) : (
<>
<Share2 size={15} aria-hidden="true" />
Share PDF
</>
)}
</button>
{purchaseOrder?.email_payload ? (
<button
onClick={handleEmail}
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
>
<Mail size={15} aria-hidden="true" />
Email PO
</button>
) : (
<button
onClick={handleCopyLink}
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
>
<Copy size={15} aria-hidden="true" />
Copy Link
</button>
)}
</div>
{copied && (
<p className="text-[11px] text-center text-[#125a3e] font-medium">
Link copied to clipboard
</p>
)}
</div>
) : (
<div className="flex items-center gap-2 py-3 px-4 rounded-xl bg-[#fcf3d8] border border-[#f6e3a4]">
<AlertCircle size={15} className="text-[#8a5a02] flex-shrink-0" />
<p className="text-[12px] text-[#8a5a02]">No document attached to this PO</p>
</div>
)}
{/* Buyer Notes */}
{hasNotes && (
<div className="space-y-2">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
Buyer Notes
</p>
<div className="bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl p-4">
<p className="text-[13px] text-[#8a5a02] leading-relaxed selectable">
{item.notes}
</p>
</div>
</div>
)}
{/* Collection / problem notes */}
{item.problem_notes && (
<div className="space-y-2">
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
Collection Notes
</p>
<div className="bg-[#fbeaef] border border-[#f3c4d2] rounded-xl p-4">
<p className="text-[13px] text-[#8a1b3a] leading-relaxed selectable">
{item.problem_notes}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-5 pt-3">
<button
onClick={onClose}
className="w-full py-3.5 rounded-xl bg-[#26251e] text-white active:bg-[#3a3832] transition-colors touch-manipulation press-feedback-sm text-[14px] font-semibold"
>
Close
</button>
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { OrderItem, Supplier } from '@/lib/types';
import { cn, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
@ -13,10 +14,14 @@ import {
Hammer,
Wrench,
AlertTriangle,
FileText,
MessageSquare,
ExternalLink,
} from 'lucide-react';
interface SupplierColor {
dot: string;
border: string;
light: string;
text: string;
ring: string;
@ -32,6 +37,7 @@ interface SupplierGroupProps {
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
onShowDetails: (item: OrderItem) => void;
}
function getItemIcon(item: OrderItem) {
@ -53,17 +59,36 @@ export default function SupplierGroup({
onCollect,
onProblem,
onNavigate,
onShowDetails,
}: 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);
const listRef = useRef<HTMLUListElement>(null);
// 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="bg-[#e6e5e0] rounded-2xl overflow-hidden transition-all duration-200 border border-[rgba(38,37,30,0.1)]"
className={cn(
'relative z-10 bg-[#e6e5e0] rounded-2xl overflow-visible transition-all duration-200 border border-[rgba(38,37,30,0.1)]',
'border-l-[6px]',
color.border
)}
aria-label={`${supplier.suppliername} collection group`}
>
{/* Supplier Header — always visible, tap to expand/collapse */}
<button
onClick={onToggle}
aria-expanded={isExpanded}
@ -128,15 +153,25 @@ export default function SupplierGroup({
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
>
{viewMode === 'compact' ? (
/* COMPACT MODE: simple list with item name + qty */
<ul className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
/* COMPACT MODE: simple list with FileText + MessageSquare buttons */
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
{items.map((item) => {
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
key={item.id}
className="flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors"
className="relative flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors"
>
{/* 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">
@ -148,8 +183,143 @@ export default function SupplierGroup({
</span>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-2">
{/* Right: qty + FileText + Collect/Problem */}
<div className="flex items-center gap-2 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();
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
}}
aria-label="Show PO details"
aria-expanded={isPoOpen}
className="w-8 h-8 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 */}
{isPoOpen && (
<div
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">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();
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
}}
aria-label="Show notes"
aria-expanded={isNotesOpen}
className="w-8 h-8 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 */}
{isNotesOpen && (
<div
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(); 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)}
@ -161,7 +331,7 @@ export default function SupplierGroup({
<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-[#8a1b3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
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>
@ -183,6 +353,7 @@ export default function SupplierGroup({
onCollect={onCollect}
onProblem={onProblem}
onNavigate={onNavigate}
onShowDetails={onShowDetails}
/>
))}
</div>

View file

@ -238,49 +238,60 @@ export const updateCollectionStatus = async (
};
// ---------- Attachments ----------
// Upload a file (typically a pickup photo) as an OrderItemAttachment
// row in PB's `order_item_attachments` collection. Mirrors the
// quote_item_attachments flow: parent via the `order_item` relation,
// file carried in the `file` field, plus content_type / size_bytes
// for the operator side to surface in their list view.
// Upload a pickup photo by appending it to the order_item's
// `order_item_attachements` file field (PB file field, max multi).
// Uses the `fieldName+` append syntax so each upload is preserved
// alongside any previous files rather than replacing them.
//
// Returns the new attachment record. Throws on PB errors so the
// caller can roll back the status change (otherwise the driver
// sees a "Collected" toast while the photo is lost).
// The source/notes params are accepted for interface compatibility
// but cannot be stored per-file without a separate attachment collection.
// Both are safe to drop here since the driver always uses
// source=driver_proof and notes go into order_items.notes separately.
//
// Throws on PB errors so the caller can roll back the status change.
export const uploadOrderItemAttachment = async (params: {
orderItemId: string;
file: File;
source?: AttachmentSource;
notes?: string;
}): Promise<OrderItemAttachment> => {
const { orderItemId, file, source = 'driver_proof', notes } = params;
const { orderItemId, file } = params;
// Build a FormData payload. PB's create() accepts a plain object
// with the file property, which it then serializes as multipart.
const formData = new FormData();
formData.append('order_item', orderItemId);
formData.append('filename', file.name);
formData.append('file', file);
formData.append('content_type', file.type || '');
if (typeof file.size === 'number') {
formData.append('size_bytes', String(file.size));
}
formData.append('source', source);
if (notes) formData.append('notes', notes);
// fieldName+ = append (never overwrite) existing files on the row
formData.append('order_item_attachements+', file);
const created = await pb.collection('order_item_attachments').create(formData);
return created as unknown as OrderItemAttachment;
const updated = await pb.collection('order_items').update(orderItemId, formData);
// PB returns the updated order_item; extract the last filename as the
// "id" for the returned OrderItemAttachment shape.
const filenames: string[] = (updated as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? [];
const filename = filenames[filenames.length - 1] ?? file.name;
return {
id: filename, // no separate attachment id; filename is the key
order_item: orderItemId,
filename,
file: filename,
source: 'driver_proof',
uploaded_at: new Date().toISOString(),
} as unknown as OrderItemAttachment;
};
// List attachments for a single order item. Newest first.
// List attachment filenames for an order item by fetching the file list
// from the order_item_attachements file field on the order_items row.
export const listOrderItemAttachments = async (
orderItemId: string
): Promise<OrderItemAttachment[]> => {
const records = await pb.collection('order_item_attachments').getFullList({
filter: `order_item = "${orderItemId}"`,
sort: '-uploaded_at',
});
return records as unknown as OrderItemAttachment[];
const item = await pb.collection('order_items').getOne(orderItemId);
const filenames: string[] = (item as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? [];
return filenames.map((filename) => ({
id: filename,
order_item: orderItemId,
filename,
file: filename,
source: 'driver_proof' as const,
uploaded_at: '',
})) as unknown as OrderItemAttachment[];
};
// ---------- Display helpers ----------

View file

@ -20,6 +20,28 @@ export interface PurchaseOrder {
id: string;
po_number: string;
status: string;
// Attached PO PDF documents (file field on the purchase_orders PB collection)
documents?: string[];
// Pre-composed email payload (subject, body, to) — driver can resend
// this to the supplier via the "Email PO" button when the supplier
// hasn't received it.
email_payload?: {
to: string;
subject: string;
body: string;
bodyText: string;
cc?: string;
bcc?: string;
};
// Linked supplier (expanded)
expand?: {
supplier?: Supplier;
};
supplier?: string;
total_amount?: number;
order?: string;
order_item?: string;
updated?: string;
}
export interface OrderItem {
@ -81,7 +103,14 @@ export interface OrderItem {
// uploads go through the order_item_attachments collection so we
// can carry multiple files per pickup (camera, library, signed
// delivery note scan) without overwriting history.
collection_photo?: string;
// Multiple pickup proof files (photos, signed slips, etc.) appended
// directly to the order_item row via PB's file field. Each upload
expand?: {
supplier?: Supplier; // PB expand key matches relation field name
order?: Order;
purchase_order?: PurchaseOrder;
};
order_item_attachements?: string[];
// Per-row quantities used for proving/dashboard math.
qty_processed?: number;
qty_assigned?: number;
@ -97,12 +126,6 @@ export interface OrderItem {
is_stock_item?: boolean;
collected_date?: string;
collection_date?: string;
// Expanded fields
expand?: {
supplier?: Supplier; // PB expand key matches relation field name
order?: Order;
purchase_order?: PurchaseOrder;
};
}
export interface DriverLocation {

90
package-lock.json generated
View file

@ -278,9 +278,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -298,9 +295,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -318,9 +312,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -338,9 +329,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -358,9 +346,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -378,9 +363,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -398,9 +380,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -418,9 +397,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -438,9 +414,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -464,9 +437,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -490,9 +460,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -516,9 +483,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -542,9 +506,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -568,9 +529,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -594,9 +552,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -620,9 +575,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -878,9 +830,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -897,9 +846,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -916,9 +862,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -935,9 +878,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1563,9 +1503,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1580,9 +1517,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1597,9 +1531,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1614,9 +1545,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1631,9 +1559,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1648,9 +1573,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1665,9 +1587,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1682,9 +1601,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1699,9 +1615,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1716,9 +1629,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View file

@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 4001",
"dev": "next dev -p 4002",
"build": "next build",
"export": "NEXT_EXPORT=true next build",
"start": "next start",

View file

@ -35,7 +35,6 @@ const FILTERS = [
{ collection: 'order_items', filter: 'drv_id = "placeholder" && item_status = "assigned"' },
{ collection: 'order_items', filter: 'item_status = "collection_pool"' },
{ collection: 'order_items', filter: 'problem_flag = true || item_status = "problem"' },
{ collection: 'order_item_attachments', filter: 'order_item = "placeholder"' },
{ collection: 'driver_locations', filter: 'relation = "placeholder" && timestamp >= "2026-01-01T00:00:00Z"' },
];