"use client"; import { useState, useEffect, useCallback } from "react"; import useSWR from "swr"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import SupplierPackingTab from "@/components/supplier-packing-tab"; import { Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Truck, Plus, Search, FileDown, Trash2, Eye, Package, Hash, X, AlertTriangle, CheckCircle2, } from "lucide-react"; import { useTableSort } from "@/hooks/use-table-sort"; import { SortableTableHead } from "@/components/sortable-table-head"; import { toast } from "sonner"; import type { PurchaseOrder, PackingList } from "@/lib/types"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); interface POItemForPacking { id: number; product_id: number; product_name: string; product_sku: string; quantity: number; shipped_quantity: number; remaining_quantity: number; } export default function LogisticsPage() { const { data: packingListsRaw, mutate } = useSWR( "/api/packing-lists", fetcher ); const packingLists = Array.isArray(packingListsRaw) ? packingListsRaw : []; const { data: ordersRaw } = useSWR( "/api/orders?status=confirmed,in_production", fetcher ); const orders = Array.isArray(ordersRaw) ? ordersRaw : []; const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [viewTarget, setViewTarget] = useState(null); const [viewData, setViewData] = useState<(PackingList & { serials: { serial_number: string }[] }) | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); // Create form state const [selectedOrderId, setSelectedOrderId] = useState(""); const [poItems, setPoItems] = useState([]); const [selectedPoItemId, setSelectedPoItemId] = useState(""); const [packingNumber, setPackingNumber] = useState(""); const [serialInput, setSerialInput] = useState(""); const [serialNumbers, setSerialNumbers] = useState([]); const [notes, setNotes] = useState(""); const [creating, setCreating] = useState(false); const [loadingItems, setLoadingItems] = useState(false); const [existingSerials, setExistingSerials] = useState([]); // Load PO items and existing serials when order changes useEffect(() => { if (!selectedOrderId) { setPoItems([]); setSelectedPoItemId(""); setExistingSerials([]); return; } setLoadingItems(true); Promise.all([ fetch(`/api/orders/${selectedOrderId}/items`).then((r) => r.json()), fetch(`/api/packing-lists?orderId=${selectedOrderId}`).then((r) => r.json()), ]) .then(([items, plsForOrder]) => { if (Array.isArray(items)) { setPoItems(items.filter((i: POItemForPacking) => Number(i.remaining_quantity) > 0)); } // Collect all serial numbers already used in this PO's packing lists if (Array.isArray(plsForOrder)) { const plIds = plsForOrder.map((pl: PackingList) => pl.id); if (plIds.length > 0) { Promise.all( plIds.map((id: number) => fetch(`/api/packing-lists/${id}`).then((r) => r.json()) ) ).then((details) => { const allSerials: string[] = []; for (const detail of details) { if (detail.serials) { for (const s of detail.serials) { allSerials.push(s.serial_number); } } } setExistingSerials(allSerials); }); } else { setExistingSerials([]); } } }) .finally(() => setLoadingItems(false)); }, [selectedOrderId]); // Fetch packing list detail for view useEffect(() => { if (!viewTarget) { setViewData(null); return; } fetch(`/api/packing-lists/${viewTarget}`) .then((r) => r.json()) .then(setViewData); }, [viewTarget]); // Generate packing number preview: PL-EACH-YYYY_MM_DD-RR-CUSTOMER useEffect(() => { if (!selectedOrderId || !orders) { setPackingNumber(""); return; } const order = orders.find((o) => String(o.id) === selectedOrderId); if (!order) return; const customerName = (order.customer_name || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); const datePrefix = `PL-EACH-${yyyy}_${mm}_${dd}`; // Count how many existing packing lists match this date + customer const existing = (packingLists || []).filter((pl) => pl.packing_number.startsWith(datePrefix) && pl.packing_number.endsWith(`-${customerName}`) ).length; const revision = String(existing + 1).padStart(2, "0"); setPackingNumber(`${datePrefix}-${revision}-${customerName}`); }, [selectedOrderId, orders, packingLists]); function resetCreateForm() { setSelectedOrderId(""); setPoItems([]); setSelectedPoItemId(""); setSerialInput(""); setSerialNumbers([]); setNotes(""); setCreating(false); } function addSerial() { const trimmed = serialInput.trim(); if (!trimmed) return; if (serialNumbers.includes(trimmed)) { toast.error("Duplicate serial number in this list"); return; } if (existingSerials.includes(trimmed)) { toast.error(`Serial number "${trimmed}" is already used in another packing list for this PO`); return; } const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId); if (selectedItem && serialNumbers.length >= Number(selectedItem.remaining_quantity)) { toast.error(`Maximum ${selectedItem.remaining_quantity} serial numbers allowed`); return; } setSerialNumbers((prev) => [...prev, trimmed]); setSerialInput(""); } function removeSerial(sn: string) { setSerialNumbers((prev) => prev.filter((s) => s !== sn)); } async function handleCreate() { if (!selectedOrderId || !selectedPoItemId || !serialNumbers.length) { toast.error("Select a PO, product, and add at least one serial number"); return; } setCreating(true); try { const res = await fetch("/api/packing-lists", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ purchase_order_id: Number(selectedOrderId), po_item_id: Number(selectedPoItemId), serial_numbers: serialNumbers, notes: notes || null, }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to create packing list"); } toast.success("Packing list created"); mutate(); setCreateOpen(false); resetCreateForm(); } catch (err) { toast.error(err instanceof Error ? err.message : "Error creating packing list"); } finally { setCreating(false); } } async function handleDelete() { if (!deleteTarget) return; try { const res = await fetch(`/api/packing-lists/${deleteTarget.id}`, { method: "DELETE" }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to delete"); } toast.success("Packing list deleted and shipped quantity reverted"); mutate(); } catch (err) { toast.error(err instanceof Error ? err.message : "Error deleting"); } finally { setDeleteTarget(null); } } const handleDownloadPdf = useCallback(async (plId: number) => { try { const res = await fetch(`/api/packing-lists/${plId}`); const data = await res.json(); const { generatePackingPdf } = await import("@/lib/generate-packing-pdf"); const doc = generatePackingPdf(data); doc.save(`${data.packing_number}.pdf`); } catch { toast.error("Failed to generate PDF"); } }, []); const filtered = (packingLists || []).filter((pl) => { if (!search) return true; const s = search.toLowerCase(); return ( pl.packing_number.toLowerCase().includes(s) || (pl.po_number || "").toLowerCase().includes(s) || (pl.customer_name || "").toLowerCase().includes(s) || (pl.product_name || "").toLowerCase().includes(s) ); }); const { sorted: sortedFiltered, sort, toggleSort } = useTableSort(filtered); const selectedItem = poItems.find((i) => String(i.id) === selectedPoItemId); return (

Logistics

Create packing lists for customers and suppliers, and generate shipping documents

Customer Packing Lists Supplier Packing Lists
{/* Summary Cards */}

{filtered.length}

Total Packing Lists

{filtered.reduce((s, pl) => s + Number(pl.quantity), 0)}

Units Shipped

{filtered.reduce((s, pl) => s + Number(pl.serial_count || pl.quantity), 0)}

Serial Numbers Tracked

{/* Search */}
setSearch(e.target.value)} />
{/* Packing Lists Table */} Packing Lists Actions {sortedFiltered.length === 0 ? ( No packing lists found ) : ( sortedFiltered.map((pl) => ( {pl.packing_number} {pl.po_number}
{pl.customer_name} ({pl.customer_code})
{pl.product_name} {pl.product_sku}
{Number(pl.quantity)} {Number(pl.serial_count || pl.quantity)} {new Date(pl.created_at).toLocaleDateString("en-GB")}
)) )}
{/* Create Packing List Dialog */} { setCreateOpen(open); if (!open) resetCreateForm(); }} > New Packing List
{/* Packing Number (auto-generated) */}

Auto-generated: PL-EACH-YYYY_MM_DD-revision-CUSTOMER

{/* Select Purchase Order */}
{/* Select PO Item (product) */} {selectedOrderId && (
{loadingItems ? (

Loading items...

) : poItems.length === 0 ? (
All items fully shipped on this order
) : ( )}
)} {/* Remaining info */} {selectedItem && (
{selectedItem.product_name} ({selectedItem.product_sku})
Ordered: {Number(selectedItem.quantity)} Shipped: {Number(selectedItem.shipped_quantity)} {Number(selectedItem.remaining_quantity)} remaining
)} {/* Serial Numbers */} {selectedPoItemId && (
setSerialInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSerial(); } }} disabled={ !!selectedItem && serialNumbers.length >= Number(selectedItem.remaining_quantity) } />
{serialNumbers.length > 0 && (
# Serial Number {serialNumbers.map((sn, i) => ( {i + 1} {sn} ))}
)}
)} {/* Notes */}