From 85c63b336c12ffb2f7458c619be0ffedf59142bc Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sun, 2 Aug 2026 21:38:38 +0200 Subject: [PATCH] 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 --- app/page.tsx | 35 ++- components/AreaGroup.tsx | 4 +- components/CollectionItemCard.tsx | 359 ++++++++++++++++++++++++------ components/ItemDetailModal.tsx | 308 +++++++++++++++++++++++++ components/SupplierGroup.tsx | 185 ++++++++++++++- lib/pocketbase.ts | 67 +++--- lib/types.ts | 37 ++- package-lock.json | 90 -------- package.json | 2 +- scripts/check-pb-schema.mjs | 1 - 10 files changed, 875 insertions(+), 213 deletions(-) create mode 100644 components/ItemDetailModal.tsx diff --git a/app/page.tsx b/app/page.tsx index 22fc729..852fa83 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(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} /> + { setShowDetailModal(false); setSelectedItem(null); }} + /> {/* Toast */} {toast && ( diff --git a/components/AreaGroup.tsx b/components/AreaGroup.tsx index 98ef528..7b9dd1e 100644 --- a/components/AreaGroup.tsx +++ b/components/AreaGroup.tsx @@ -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 ; return ; } - 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} /> ))} diff --git a/components/CollectionItemCard.tsx b/components/CollectionItemCard.tsx index f5b3df8..ffb3b1b 100644 --- a/components/CollectionItemCard.tsx +++ b/components/CollectionItemCard.tsx @@ -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: , - color: 'text-[#8a5a02]', - bgColor: 'bg-[#fcf3d8]', - label: 'Breakdown', + type: 'stock', + icon: , + color: 'text-[#4a3d6b]', + bgColor: 'bg-[#d4cae3]', + label: 'Stock', }; } - if (orderNumber.toLowerCase().includes('repair')) { + if (orderNumber.startsWith('RPR') || poNumber.startsWith('RPR')) { return { type: 'repair', icon: , @@ -67,13 +73,13 @@ function getItemType(item: OrderItem): { }; } - if (item.is_stock_item) { + if (orderNumber.startsWith('BRK') || poNumber.startsWith('BRK')) { return { - type: 'stock', - icon: , - color: 'text-[#4a3d6b]', - bgColor: 'bg-[#ece8f3]', - label: 'Stock', + type: 'breakdown', + icon: , + 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, 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(null); + const poTooltipRef = useRef(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 (
{itemType.icon} + {/* Main content */}
-

+

{description}

+ {/* PO and Order numbers — always visible in compact mode */} + {compact && (poNumber || orderNumber) && ( +
+ {poNumber && ( + + PO: {poNumber} + + )} + {poNumber && orderNumber && ( + + )} + {orderNumber && ( + + Order: {orderNumber} + + )} +
+ )} + {/* 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({ )}
- {supplier?.suppliername && ( + {supplier?.suppliername && !compact && (
-
- {item.selling_price && expectedQty > 0 && ( - - {formatCurrency(item.selling_price * expectedQty)} - - )} - {expanded ? ( -
+ {/* Right side — tooltip buttons always visible in compact mode */} + {compact ? ( +
+ {/* Price + chevron row */} +
+ {item.selling_price && expectedQty > 0 && ( + + {formatCurrency(item.selling_price * expectedQty)} + + )} + {expanded ? ( +
+ {/* Always-visible tooltip buttons — shown as a row of 2 */} +
+ {/* PO tooltip button */} +
+ + {/* PO tooltip popover */} + {showPoTooltip && ( +
e.stopPropagation()} + > + {po ? ( + <> +
+

Purchase Order

+ +
+
+
+
PO Number
+
{po.po_number}
+
+ {po.status && ( +
+
Status
+
{po.status}
+
+ )} + {po.total_amount != null && ( +
+
Total
+
R{(po.total_amount / 100).toFixed(2)}
+
+ )} + {orderNumber && ( +
+
Order
+
{orderNumber}
+
+ )} +
+ + + ) : ( +
+

No PO attached

+ +
+ )} +
+ )} +
+ + {/* Notes tooltip button */} + {hasAnyNotes && ( +
+ + {/* Notes tooltip popover */} + {showNotesTooltip && ( +
e.stopPropagation()} + > +
+

Buyer Notes

+ +
+ {hasNotes ? ( + <> +

+ {item.notes} +

+ {hasProblemNotes && ( +

+ Problem: {item.problem_notes} +

+ )} + + ) : hasProblemNotes ? ( +

+ {item.problem_notes} +

+ ) : ( +

No notes

+ )} +
+ )} +
+ )} +
+
+ ) : ( + /* Full/non-compact right side */ +
+ {item.selling_price && expectedQty > 0 && ( + + {formatCurrency(item.selling_price * expectedQty)} + + )} + {expanded ? ( +
+ )} @@ -222,16 +422,16 @@ export default function CollectionItemCard({ {/* Details grid */}
- {item.expand?.order?.order_number && ( + {orderNumber && (
Order
-
{item.expand.order.order_number}
+
{orderNumber}
)} - {item.expand?.purchase_order?.po_number && ( + {poNumber && (
PO
-
{item.expand.purchase_order.po_number}
+
{poNumber}
)} {supplier?.supplieraddress && ( @@ -241,46 +441,69 @@ export default function CollectionItemCard({ )}
- {/* Action Buttons */} -
- - - - - {hasCoords && coords && supplier ? ( +
+ {/* Row 1 — core workflow */} +
- ) : ( -
-
- )} + + + + {hasCoords && coords && supplier ? ( + + ) : ( +
+
+ )} +
+ + {/* Row 2 — PO document and buyer notes */} +
+ + + +
)}
); -} \ No newline at end of file +} diff --git a/components/ItemDetailModal.tsx b/components/ItemDetailModal.tsx new file mode 100644 index 0000000..a309472 --- /dev/null +++ b/components/ItemDetailModal.tsx @@ -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 ( +
+ {/* Backdrop */} + + ); +} diff --git a/components/SupplierGroup.tsx b/components/SupplierGroup.tsx index 179ac10..4b9f2d4 100644 --- a/components/SupplierGroup.tsx +++ b/components/SupplierGroup.tsx @@ -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(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 (
- {/* Supplier Header — always visible, tap to expand/collapse */} + {/* PO tooltip popover */} + {isPoOpen && ( +
e.stopPropagation()} + > +
+

Purchase Order

+ +
+ {po ? ( + <> +
+
+
PO Number
+
{po.po_number}
+
+ {po.status && ( +
+
Status
+
{po.status}
+
+ )} + {po.total_amount != null && ( +
+
Total
+
R{(po.total_amount / 100).toFixed(2)}
+
+ )} + {orderNumber && ( +
+
Order
+
{orderNumber}
+
+ )} +
+ + + ) : ( +
+

No PO attached

+ +
+ )} +
+ )} +
+ + {/* Notes MessageSquare button — only when notes exist */} + {hasAnyNotes && ( +
+ + {/* Notes tooltip popover */} + {isNotesOpen && ( +
e.stopPropagation()} + > +
+

Buyer Notes

+ +
+ {hasNotes ? ( + <> +

+ {item.notes} +

+ {hasProblemNotes && ( +

+ Problem: {item.problem_notes} +

+ )} + + ) : hasProblemNotes ? ( +

+ {item.problem_notes} +

+ ) : ( +

No notes

+ )} +
+ )} +
+ )} + + {/* Collect + Problem buttons */}
@@ -183,6 +353,7 @@ export default function SupplierGroup({ onCollect={onCollect} onProblem={onProblem} onNavigate={onNavigate} + onShowDetails={onShowDetails} /> ))}
diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts index a298f78..3dfbae0 100644 --- a/lib/pocketbase.ts +++ b/lib/pocketbase.ts @@ -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 => { - 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 => { - 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 ---------- diff --git a/lib/types.ts b/lib/types.ts index 7598b7b..e26d88d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 { diff --git a/package-lock.json b/package-lock.json index dec3f75..c3952c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/package.json b/package.json index e359b9d..dd143ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-pb-schema.mjs b/scripts/check-pb-schema.mjs index a617532..fd6cd8c 100644 --- a/scripts/check-pb-schema.mjs +++ b/scripts/check-pb-schema.mjs @@ -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"' }, ];