818 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|