Files
website-secure/app/logistics/page.tsx
T
pm 9c14a19a87
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
chore: upload parte 1 - logica
2026-06-01 15:35:53 +01:00

818 lines
31 KiB
TypeScript

"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<PackingList[]>(
"/api/packing-lists",
fetcher
);
const packingLists = Array.isArray(packingListsRaw) ? packingListsRaw : [];
const { data: ordersRaw } = useSWR<PurchaseOrder[]>(
"/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<number | null>(null);
const [viewData, setViewData] = useState<(PackingList & { serials: { serial_number: string }[] }) | null>(null);
const [deleteTarget, setDeleteTarget] = useState<PackingList | null>(null);
// Create form state
const [selectedOrderId, setSelectedOrderId] = useState("");
const [poItems, setPoItems] = useState<POItemForPacking[]>([]);
const [selectedPoItemId, setSelectedPoItemId] = useState("");
const [packingNumber, setPackingNumber] = useState("");
const [serialInput, setSerialInput] = useState("");
const [serialNumbers, setSerialNumbers] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [creating, setCreating] = useState(false);
const [loadingItems, setLoadingItems] = useState(false);
const [existingSerials, setExistingSerials] = useState<string[]>([]);
// 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 (
<div className="flex flex-col gap-6 p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Logistics</h1>
<p className="text-sm text-muted-foreground">
Create packing lists for customers and suppliers, and generate shipping documents
</p>
</div>
<Tabs defaultValue="customer" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="customer">Customer Packing Lists</TabsTrigger>
<TabsTrigger value="supplier">Supplier Packing Lists</TabsTrigger>
</TabsList>
<TabsContent value="customer">
<div className="mb-6 flex justify-end">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Packing List
</Button>
</div>
{/* Summary Cards */}
<div className="mb-6 grid gap-4 grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Truck className="h-8 w-8 text-primary" />
<div>
<p className="text-2xl font-bold">{filtered.length}</p>
<p className="text-xs text-muted-foreground">Total Packing Lists</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-accent" />
<div>
<p className="text-2xl font-bold">
{filtered.reduce((s, pl) => s + Number(pl.quantity), 0)}
</p>
<p className="text-xs text-muted-foreground">Units Shipped</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Hash className="h-8 w-8 text-warning" />
<div>
<p className="text-2xl font-bold">
{filtered.reduce((s, pl) => s + Number(pl.serial_count || pl.quantity), 0)}
</p>
<p className="text-xs text-muted-foreground">Serial Numbers Tracked</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<Card className="mb-6">
<CardContent className="py-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by packing number, PO number, customer, or product..."
className="pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Packing Lists Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="h-4 w-4 text-primary" />
Packing Lists
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="packing_number" label="Packing No." sort={sort} onToggle={toggleSort} />
<SortableTableHead column="po_number" label="PO Number" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="customer_name" label="Customer" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="product_name" label="Product" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="quantity" label="Qty" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="serial_count" label="Serials" sort={sort} onToggle={toggleSort} className="text-right" />
<SortableTableHead column="created_at" label="Date" sort={sort} onToggle={toggleSort} />
<TableHead className="w-[130px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedFiltered.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
No packing lists found
</TableCell>
</TableRow>
) : (
sortedFiltered.map((pl) => (
<TableRow key={pl.id}>
<TableCell className="font-mono text-sm font-semibold">
{pl.packing_number}
</TableCell>
<TableCell className="font-mono text-xs">
{pl.po_number}
</TableCell>
<TableCell>
<div>
<span className="font-medium text-sm">{pl.customer_name}</span>
<span className="ml-2 text-xs text-muted-foreground">
({pl.customer_code})
</span>
</div>
</TableCell>
<TableCell>
<div>
<span className="text-sm">{pl.product_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{pl.product_sku}
</span>
</div>
</TableCell>
<TableCell className="text-right font-mono font-medium">
{Number(pl.quantity)}
</TableCell>
<TableCell className="text-right font-mono">
{Number(pl.serial_count || pl.quantity)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(pl.created_at).toLocaleDateString("en-GB")}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setViewTarget(pl.id)}
title="View details"
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-primary hover:text-primary"
onClick={() => handleDownloadPdf(pl.id)}
title="Download PDF"
>
<FileDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(pl)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="supplier">
<SupplierPackingTab />
</TabsContent>
</Tabs>
{/* Create Packing List Dialog */}
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Truck className="h-5 w-5 text-primary" />
New Packing List
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5 py-2">
{/* Packing Number (auto-generated) */}
<div className="flex flex-col gap-2">
<Label>Packing List Number</Label>
<Input
value={packingNumber || "Select a purchase order..."}
readOnly
className="font-mono bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
Auto-generated: PL-EACH-YYYY_MM_DD-revision-CUSTOMER
</p>
</div>
{/* Select Purchase Order */}
<div className="flex flex-col gap-2">
<Label>Purchase Order</Label>
<Select value={selectedOrderId} onValueChange={setSelectedOrderId}>
<SelectTrigger>
<SelectValue placeholder="Select a purchase order..." />
</SelectTrigger>
<SelectContent>
{(orders || []).map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.po_number} - {o.customer_name} ({o.customer_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Select PO Item (product) */}
{selectedOrderId && (
<div className="flex flex-col gap-2">
<Label>Product Line</Label>
{loadingItems ? (
<p className="text-sm text-muted-foreground">Loading items...</p>
) : poItems.length === 0 ? (
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-accent" />
All items fully shipped on this order
</div>
) : (
<Select value={selectedPoItemId} onValueChange={setSelectedPoItemId}>
<SelectTrigger>
<SelectValue placeholder="Select a product..." />
</SelectTrigger>
<SelectContent>
{poItems.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.product_name} ({item.product_sku}) - {Number(item.remaining_quantity)} remaining
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Remaining info */}
{selectedItem && (
<div className="rounded-md border border-border bg-muted/50 p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedItem.product_name} ({selectedItem.product_sku})
</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Ordered: <strong className="text-foreground">{Number(selectedItem.quantity)}</strong>
</span>
<span className="text-muted-foreground">
Shipped: <strong className="text-foreground">{Number(selectedItem.shipped_quantity)}</strong>
</span>
<Badge variant="secondary">
{Number(selectedItem.remaining_quantity)} remaining
</Badge>
</div>
</div>
</div>
)}
{/* Serial Numbers */}
{selectedPoItemId && (
<div className="flex flex-col gap-2">
<Label className="flex items-center justify-between">
<span>
Serial Numbers ({serialNumbers.length}
{selectedItem
? ` / ${Number(selectedItem.remaining_quantity)}`
: ""}
)
</span>
{selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity) && (
<Badge className="bg-accent text-accent-foreground text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
All serial numbers entered
</Badge>
)}
</Label>
<div className="flex gap-2">
<Input
placeholder="Enter serial number and press Enter..."
className="font-mono"
value={serialInput}
onChange={(e) => setSerialInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSerial();
}
}}
disabled={
!!selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity)
}
/>
<Button
type="button"
variant="secondary"
onClick={addSerial}
disabled={
!serialInput.trim() ||
(!!selectedItem &&
serialNumbers.length >= Number(selectedItem.remaining_quantity))
}
>
Add
</Button>
</div>
{serialNumbers.length > 0 && (
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{serialNumbers.map((sn, i) => (
<TableRow key={sn}>
<TableCell className="text-xs text-muted-foreground">
{i + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{sn}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => removeSerial(sn)}
>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{/* Notes */}
<div className="flex flex-col gap-2">
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Additional shipping notes..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false);
resetCreateForm();
}}
>
Cancel
</Button>
<Button
disabled={creating || !serialNumbers.length || !selectedPoItemId}
onClick={handleCreate}
>
{creating ? "Creating..." : `Create Packing List (${serialNumbers.length} units)`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* View Detail Dialog */}
<Dialog open={!!viewTarget} onOpenChange={(open) => !open && setViewTarget(null)}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-primary" />
{viewData?.packing_number || "Loading..."}
</DialogTitle>
</DialogHeader>
{viewData && (
<div className="flex flex-col gap-4 py-2">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Customer</span>
<p className="font-medium">{viewData.customer_name}</p>
</div>
<div>
<span className="text-muted-foreground">PO Number</span>
<p className="font-mono font-medium">{viewData.po_number}</p>
</div>
<div>
<span className="text-muted-foreground">Product</span>
<p className="font-medium">{viewData.product_name}</p>
</div>
<div>
<span className="text-muted-foreground">Quantity</span>
<p className="font-mono font-bold">{Number(viewData.quantity)}</p>
</div>
<div>
<span className="text-muted-foreground">Date</span>
<p>{new Date(viewData.created_at).toLocaleDateString("en-GB")}</p>
</div>
{viewData.notes && (
<div className="col-span-2">
<span className="text-muted-foreground">Notes</span>
<p>{viewData.notes}</p>
</div>
)}
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">
Serial Numbers ({viewData.serials?.length || 0})
</h4>
<div className="max-h-60 overflow-y-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Serial Number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(viewData.serials || []).map(
(s: { serial_number: string }, i: number) => (
<TableRow key={s.serial_number}>
<TableCell className="text-xs text-muted-foreground">
{i + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{s.serial_number}
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => setViewTarget(null)}
>
Close
</Button>
<Button onClick={() => handleDownloadPdf(viewData.id)}>
<FileDown className="mr-2 h-4 w-4" />
Download PDF
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Delete Packing List?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete packing list{" "}
<strong>{deleteTarget?.packing_number}</strong> and revert the
shipped quantity ({deleteTarget?.quantity} units) on the purchase
order. All serial numbers will be released. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}