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:
parent
2a7720cd1f
commit
85c63b336c
10 changed files with 875 additions and 213 deletions
35
app/page.tsx
35
app/page.tsx
|
|
@ -10,6 +10,7 @@ import SupplierGroup from '@/components/SupplierGroup';
|
||||||
import AreaGroup from '@/components/AreaGroup';
|
import AreaGroup from '@/components/AreaGroup';
|
||||||
import CollectModal from '@/components/CollectModal';
|
import CollectModal from '@/components/CollectModal';
|
||||||
import ProblemModal from '@/components/ProblemModal';
|
import ProblemModal from '@/components/ProblemModal';
|
||||||
|
import ItemDetailModal from '@/components/ItemDetailModal';
|
||||||
import {
|
import {
|
||||||
getMyAssignedItems,
|
getMyAssignedItems,
|
||||||
getPoolItems,
|
getPoolItems,
|
||||||
|
|
@ -40,16 +41,16 @@ import {
|
||||||
// tile (not a saturated left-border stripe). Each group gets a
|
// tile (not a saturated left-border stripe). Each group gets a
|
||||||
// distinct hue but all read as part of the same family.
|
// distinct hue but all read as part of the same family.
|
||||||
const SUPPLIER_COLORS = [
|
const SUPPLIER_COLORS = [
|
||||||
{ dot: 'bg-[#f54e00]', light: 'bg-[#ebeae5]', text: 'text-[#26251e]', ring: 'ring-[#f54e00]/30' }, // orange (signature)
|
{ dot: 'bg-[#f54e00]', border: 'border-l-[#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-[#18714f]', border: 'border-l-[#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-[#6c5a99]', border: 'border-l-[#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-[#cf2d56]', border: 'border-l-[#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-[#8a5a02]', border: 'border-l-[#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-[#26251e]', border: 'border-l-[#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-[#6b6760]', border: 'border-l-[#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-[#0d9488]', border: 'border-l-[#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-[#8a1b3a]', border: 'border-l-[#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-[#4a3d6b]', border: 'border-l-[#4a3d6b]', light: 'bg-[#ece8f3]', text: 'text-[#4a3d6b]', ring: 'ring-[#6c5a99]/30' }, // indigo
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function CollectionsPage() {
|
export default function CollectionsPage() {
|
||||||
|
|
@ -75,6 +76,7 @@ export default function CollectionsPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<OrderItem | null>(null);
|
||||||
const [showCollectModal, setShowCollectModal] = useState(false);
|
const [showCollectModal, setShowCollectModal] = useState(false);
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
const [showProblemModal, setShowProblemModal] = useState(false);
|
const [showProblemModal, setShowProblemModal] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
|
const [viewMode, setViewMode] = useState<'compact' | 'detailed'>('compact');
|
||||||
|
|
@ -279,6 +281,11 @@ const loadItems = useCallback(async (showLoading = true) => {
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowDetails = (item: OrderItem) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSupplier = (supplierId: string) => {
|
const toggleSupplier = (supplierId: string) => {
|
||||||
setExpandedSuppliers(prev => {
|
setExpandedSuppliers(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -600,6 +607,7 @@ const loadItems = useCallback(async (showLoading = true) => {
|
||||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
onShowDetails={handleShowDetails}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -622,6 +630,7 @@ const loadItems = useCallback(async (showLoading = true) => {
|
||||||
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
onCollect={(item) => { setSelectedItem(item); setShowCollectModal(true); }}
|
||||||
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
onProblem={(item) => { setSelectedItem(item); setShowProblemModal(true); }}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
onShowDetails={handleShowDetails}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -643,6 +652,12 @@ const loadItems = useCallback(async (showLoading = true) => {
|
||||||
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
|
onClose={() => { setShowProblemModal(false); setSelectedItem(null); }}
|
||||||
onConfirm={handleProblem}
|
onConfirm={handleProblem}
|
||||||
/>
|
/>
|
||||||
|
<ItemDetailModal
|
||||||
|
item={selectedItem!}
|
||||||
|
purchaseOrder={selectedItem?.expand?.purchase_order ?? null}
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ interface AreaGroupProps {
|
||||||
onCollect: (item: OrderItem) => void;
|
onCollect: (item: OrderItem) => void;
|
||||||
onProblem: (item: OrderItem) => void;
|
onProblem: (item: OrderItem) => void;
|
||||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||||
|
onShowDetails: (item: OrderItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemIcon(item: OrderItem) {
|
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" />;
|
if (item.is_stock_item) return <Package size={12} className="text-violet-500" />;
|
||||||
return <ShoppingCart size={12} className="text-gray-400" />;
|
return <ShoppingCart size={12} className="text-gray-400" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AreaGroup({
|
export default function AreaGroup({
|
||||||
clusterId,
|
clusterId,
|
||||||
label,
|
label,
|
||||||
|
|
@ -58,6 +58,7 @@ export default function AreaGroup({
|
||||||
onCollect,
|
onCollect,
|
||||||
onProblem,
|
onProblem,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onShowDetails,
|
||||||
}: AreaGroupProps) {
|
}: AreaGroupProps) {
|
||||||
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
||||||
|
|
||||||
|
|
@ -216,6 +217,7 @@ export default function AreaGroup({
|
||||||
onCollect={onCollect}
|
onCollect={onCollect}
|
||||||
onProblem={onProblem}
|
onProblem={onProblem}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
onShowDetails={onShowDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { OrderItem } from '@/lib/types';
|
import { OrderItem } from '@/lib/types';
|
||||||
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
|
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
|
||||||
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
|
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
|
||||||
|
|
@ -17,6 +17,9 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface CollectionItemProps {
|
interface CollectionItemProps {
|
||||||
|
|
@ -26,6 +29,8 @@ interface CollectionItemProps {
|
||||||
onCollect: (item: OrderItem) => void;
|
onCollect: (item: OrderItem) => void;
|
||||||
onProblem: (item: OrderItem) => void;
|
onProblem: (item: OrderItem) => void;
|
||||||
onNavigate: (lat: number, lng: number, name: string) => 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): {
|
function getItemType(item: OrderItem): {
|
||||||
|
|
@ -46,18 +51,19 @@ function getItemType(item: OrderItem): {
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderNumber = item.expand?.order?.order_number || '';
|
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 {
|
return {
|
||||||
type: 'breakdown',
|
type: 'stock',
|
||||||
icon: <Hammer size={16} />,
|
icon: <Package size={16} />,
|
||||||
color: 'text-[#8a5a02]',
|
color: 'text-[#4a3d6b]',
|
||||||
bgColor: 'bg-[#fcf3d8]',
|
bgColor: 'bg-[#d4cae3]',
|
||||||
label: 'Breakdown',
|
label: 'Stock',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderNumber.toLowerCase().includes('repair')) {
|
if (orderNumber.startsWith('RPR') || poNumber.startsWith('RPR')) {
|
||||||
return {
|
return {
|
||||||
type: 'repair',
|
type: 'repair',
|
||||||
icon: <Wrench size={16} />,
|
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 {
|
return {
|
||||||
type: 'stock',
|
type: 'breakdown',
|
||||||
icon: <Package size={16} />,
|
icon: <Hammer size={16} />,
|
||||||
color: 'text-[#4a3d6b]',
|
color: 'text-[#8a5a02]',
|
||||||
bgColor: 'bg-[#ece8f3]',
|
bgColor: 'bg-[#f6e3a4]',
|
||||||
label: 'Stock',
|
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({
|
export default function CollectionItemCard({
|
||||||
item,
|
item,
|
||||||
supplierColor,
|
supplierColor,
|
||||||
|
|
@ -93,19 +112,31 @@ export default function CollectionItemCard({
|
||||||
onCollect,
|
onCollect,
|
||||||
onProblem,
|
onProblem,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onShowDetails,
|
||||||
}: CollectionItemProps) {
|
}: CollectionItemProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 supplier = item.expand?.supplier;
|
||||||
const itemType = getItemType(item);
|
const itemType = getItemType(item);
|
||||||
const coords = parseCoordinates(supplier?.coordinates);
|
const coords = parseCoordinates(supplier?.coordinates);
|
||||||
const hasCoords = coords !== null;
|
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);
|
const description = getItemDisplayDescription(item);
|
||||||
// Per the brief: prefer order_qty; falls back to quantity.
|
|
||||||
const expectedQty = getItemDisplayQty(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 (
|
return (
|
||||||
<article
|
<article
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -131,13 +162,33 @@ export default function CollectionItemCard({
|
||||||
<span className={itemType.color}>{itemType.icon}</span>
|
<span className={itemType.color}>{itemType.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<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}
|
{description}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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
|
{/* If the customer-facing description differs from the
|
||||||
supplier-facing one we just rendered, surface the
|
supplier-facing one we just rendered, surface the
|
||||||
customer one as a smaller sub-line so the driver can
|
customer one as a smaller sub-line so the driver can
|
||||||
|
|
@ -171,7 +222,7 @@ export default function CollectionItemCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supplier?.suppliername && (
|
{supplier?.suppliername && !compact && (
|
||||||
<div className="flex items-center gap-1 mt-1.5">
|
<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" />
|
<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>
|
<span className="text-xs text-[rgba(38,37,30,0.55)] truncate">{supplier.suppliername}</span>
|
||||||
|
|
@ -179,6 +230,154 @@ export default function CollectionItemCard({
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{item.selling_price && expectedQty > 0 && (
|
{item.selling_price && expectedQty > 0 && (
|
||||||
<span className="text-xs font-semibold text-[#125a3e]">
|
<span className="text-xs font-semibold text-[#125a3e]">
|
||||||
|
|
@ -191,6 +390,7 @@ export default function CollectionItemCard({
|
||||||
<ChevronDown 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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -222,16 +422,16 @@ export default function CollectionItemCard({
|
||||||
|
|
||||||
{/* Details grid */}
|
{/* Details grid */}
|
||||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||||
{item.expand?.order?.order_number && (
|
{orderNumber && (
|
||||||
<div className="bg-[#ebeae5] rounded-lg p-2">
|
<div className="bg-[#ebeae5] rounded-lg p-2">
|
||||||
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.expand?.purchase_order?.po_number && (
|
{poNumber && (
|
||||||
<div className="bg-[#ebeae5] rounded-lg p-2">
|
<div className="bg-[#ebeae5] rounded-lg p-2">
|
||||||
<dt className="text-[rgba(38,37,30,0.55)]">PO</dt>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{supplier?.supplieraddress && (
|
{supplier?.supplieraddress && (
|
||||||
|
|
@ -241,9 +441,10 @@ export default function CollectionItemCard({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="grid grid-cols-3 gap-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
|
{/* Row 1 — core workflow */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onCollect(item)}
|
onClick={() => onCollect(item)}
|
||||||
aria-label={`Mark ${description} as collected`}
|
aria-label={`Mark ${description} as collected`}
|
||||||
|
|
@ -278,6 +479,28 @@ export default function CollectionItemCard({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
308
components/ItemDetailModal.tsx
Normal file
308
components/ItemDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { OrderItem, Supplier } from '@/lib/types';
|
import { OrderItem, Supplier } from '@/lib/types';
|
||||||
import { cn, parseCoordinates } from '@/lib/utils';
|
import { cn, parseCoordinates } from '@/lib/utils';
|
||||||
import CollectionItemCard from '@/components/CollectionItemCard';
|
import CollectionItemCard from '@/components/CollectionItemCard';
|
||||||
|
|
@ -13,10 +14,14 @@ import {
|
||||||
Hammer,
|
Hammer,
|
||||||
Wrench,
|
Wrench,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SupplierColor {
|
interface SupplierColor {
|
||||||
dot: string;
|
dot: string;
|
||||||
|
border: string;
|
||||||
light: string;
|
light: string;
|
||||||
text: string;
|
text: string;
|
||||||
ring: string;
|
ring: string;
|
||||||
|
|
@ -32,6 +37,7 @@ interface SupplierGroupProps {
|
||||||
onCollect: (item: OrderItem) => void;
|
onCollect: (item: OrderItem) => void;
|
||||||
onProblem: (item: OrderItem) => void;
|
onProblem: (item: OrderItem) => void;
|
||||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||||
|
onShowDetails: (item: OrderItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemIcon(item: OrderItem) {
|
function getItemIcon(item: OrderItem) {
|
||||||
|
|
@ -53,17 +59,36 @@ export default function SupplierGroup({
|
||||||
onCollect,
|
onCollect,
|
||||||
onProblem,
|
onProblem,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onShowDetails,
|
||||||
}: SupplierGroupProps) {
|
}: SupplierGroupProps) {
|
||||||
const coords = parseCoordinates(supplier.coordinates);
|
const coords = parseCoordinates(supplier.coordinates);
|
||||||
const hasCoords = coords !== null;
|
const hasCoords = coords !== null;
|
||||||
const totalQty = items.reduce((sum, i) => sum + (i.order_qty || i.quantity || 0), 0);
|
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 (
|
return (
|
||||||
<section
|
<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`}
|
aria-label={`${supplier.suppliername} collection group`}
|
||||||
>
|
>
|
||||||
{/* Supplier Header — always visible, tap to expand/collapse */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
|
@ -128,15 +153,25 @@ export default function SupplierGroup({
|
||||||
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
className="border-t border-[rgba(38,37,30,0.08)] animate-fade-in"
|
||||||
>
|
>
|
||||||
{viewMode === 'compact' ? (
|
{viewMode === 'compact' ? (
|
||||||
/* COMPACT MODE: simple list with item name + qty */
|
/* COMPACT MODE: simple list with FileText + MessageSquare buttons */
|
||||||
<ul className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
<ul ref={listRef} className="divide-y divide-[rgba(38,37,30,0.05)]" role="list">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const expectedQty = item.order_qty || item.quantity || 0;
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
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">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
{getItemIcon(item)}
|
{getItemIcon(item)}
|
||||||
<span className="text-sm text-[#26251e] truncate">
|
<span className="text-sm text-[#26251e] truncate">
|
||||||
|
|
@ -148,8 +183,143 @@ export default function SupplierGroup({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<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 & 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">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => onCollect(item)}
|
onClick={() => onCollect(item)}
|
||||||
|
|
@ -161,7 +331,7 @@ export default function SupplierGroup({
|
||||||
<button
|
<button
|
||||||
onClick={() => onProblem(item)}
|
onClick={() => onProblem(item)}
|
||||||
aria-label={`Report problem for ${item.description || '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" />
|
<AlertTriangle size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -183,6 +353,7 @@ export default function SupplierGroup({
|
||||||
onCollect={onCollect}
|
onCollect={onCollect}
|
||||||
onProblem={onProblem}
|
onProblem={onProblem}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
onShowDetails={onShowDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -238,49 +238,60 @@ export const updateCollectionStatus = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Attachments ----------
|
// ---------- Attachments ----------
|
||||||
// Upload a file (typically a pickup photo) as an OrderItemAttachment
|
// Upload a pickup photo by appending it to the order_item's
|
||||||
// row in PB's `order_item_attachments` collection. Mirrors the
|
// `order_item_attachements` file field (PB file field, max multi).
|
||||||
// quote_item_attachments flow: parent via the `order_item` relation,
|
// Uses the `fieldName+` append syntax so each upload is preserved
|
||||||
// file carried in the `file` field, plus content_type / size_bytes
|
// alongside any previous files rather than replacing them.
|
||||||
// for the operator side to surface in their list view.
|
|
||||||
//
|
//
|
||||||
// Returns the new attachment record. Throws on PB errors so the
|
// The source/notes params are accepted for interface compatibility
|
||||||
// caller can roll back the status change (otherwise the driver
|
// but cannot be stored per-file without a separate attachment collection.
|
||||||
// sees a "Collected" toast while the photo is lost).
|
// 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: {
|
export const uploadOrderItemAttachment = async (params: {
|
||||||
orderItemId: string;
|
orderItemId: string;
|
||||||
file: File;
|
file: File;
|
||||||
source?: AttachmentSource;
|
source?: AttachmentSource;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<OrderItemAttachment> => {
|
}): 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();
|
const formData = new FormData();
|
||||||
formData.append('order_item', orderItemId);
|
// fieldName+ = append (never overwrite) existing files on the row
|
||||||
formData.append('filename', file.name);
|
formData.append('order_item_attachements+', file);
|
||||||
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);
|
|
||||||
|
|
||||||
const created = await pb.collection('order_item_attachments').create(formData);
|
const updated = await pb.collection('order_items').update(orderItemId, formData);
|
||||||
return created as unknown as OrderItemAttachment;
|
// 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 (
|
export const listOrderItemAttachments = async (
|
||||||
orderItemId: string
|
orderItemId: string
|
||||||
): Promise<OrderItemAttachment[]> => {
|
): Promise<OrderItemAttachment[]> => {
|
||||||
const records = await pb.collection('order_item_attachments').getFullList({
|
const item = await pb.collection('order_items').getOne(orderItemId);
|
||||||
filter: `order_item = "${orderItemId}"`,
|
const filenames: string[] = (item as unknown as { order_item_attachements?: string[] }).order_item_attachements ?? [];
|
||||||
sort: '-uploaded_at',
|
return filenames.map((filename) => ({
|
||||||
});
|
id: filename,
|
||||||
return records as unknown as OrderItemAttachment[];
|
order_item: orderItemId,
|
||||||
|
filename,
|
||||||
|
file: filename,
|
||||||
|
source: 'driver_proof' as const,
|
||||||
|
uploaded_at: '',
|
||||||
|
})) as unknown as OrderItemAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Display helpers ----------
|
// ---------- Display helpers ----------
|
||||||
|
|
|
||||||
37
lib/types.ts
37
lib/types.ts
|
|
@ -20,6 +20,28 @@ export interface PurchaseOrder {
|
||||||
id: string;
|
id: string;
|
||||||
po_number: string;
|
po_number: string;
|
||||||
status: 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 {
|
export interface OrderItem {
|
||||||
|
|
@ -81,7 +103,14 @@ export interface OrderItem {
|
||||||
// uploads go through the order_item_attachments collection so we
|
// uploads go through the order_item_attachments collection so we
|
||||||
// can carry multiple files per pickup (camera, library, signed
|
// can carry multiple files per pickup (camera, library, signed
|
||||||
// delivery note scan) without overwriting history.
|
// 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.
|
// Per-row quantities used for proving/dashboard math.
|
||||||
qty_processed?: number;
|
qty_processed?: number;
|
||||||
qty_assigned?: number;
|
qty_assigned?: number;
|
||||||
|
|
@ -97,12 +126,6 @@ export interface OrderItem {
|
||||||
is_stock_item?: boolean;
|
is_stock_item?: boolean;
|
||||||
collected_date?: string;
|
collected_date?: string;
|
||||||
collection_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 {
|
export interface DriverLocation {
|
||||||
|
|
|
||||||
90
package-lock.json
generated
90
package-lock.json
generated
|
|
@ -278,9 +278,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -298,9 +295,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -318,9 +312,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -338,9 +329,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -358,9 +346,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -378,9 +363,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -398,9 +380,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -418,9 +397,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -438,9 +414,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -464,9 +437,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -490,9 +460,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -516,9 +483,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -542,9 +506,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -568,9 +529,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -594,9 +552,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -620,9 +575,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -878,9 +830,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -897,9 +846,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -916,9 +862,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -935,9 +878,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1563,9 +1503,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1580,9 +1517,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1597,9 +1531,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1614,9 +1545,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1631,9 +1559,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1648,9 +1573,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1665,9 +1587,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1682,9 +1601,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1699,9 +1615,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1716,9 +1629,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4001",
|
"dev": "next dev -p 4002",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"export": "NEXT_EXPORT=true next build",
|
"export": "NEXT_EXPORT=true next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ const FILTERS = [
|
||||||
{ collection: 'order_items', filter: 'drv_id = "placeholder" && item_status = "assigned"' },
|
{ collection: 'order_items', filter: 'drv_id = "placeholder" && item_status = "assigned"' },
|
||||||
{ collection: 'order_items', filter: 'item_status = "collection_pool"' },
|
{ collection: 'order_items', filter: 'item_status = "collection_pool"' },
|
||||||
{ collection: 'order_items', filter: 'problem_flag = true || item_status = "problem"' },
|
{ 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"' },
|
{ collection: 'driver_locations', filter: 'relation = "placeholder" && timestamp >= "2026-01-01T00:00:00Z"' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue