773 lines
27 KiB
TypeScript
773 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import useSWR from "swr";
|
|
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 {
|
|
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 { useTableSort } from "@/hooks/use-table-sort";
|
|
import { SortableTableHead } from "@/components/sortable-table-head";
|
|
import {
|
|
Building2,
|
|
Plus,
|
|
Search,
|
|
FileDown,
|
|
Trash2,
|
|
Eye,
|
|
Package,
|
|
X,
|
|
AlertTriangle,
|
|
Cpu,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import type { Supplier, SupplierPackingList, Component as ComponentType } from "@/lib/types";
|
|
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
interface SupplierPackingItem {
|
|
id: number;
|
|
component_id: number;
|
|
quantity: number;
|
|
part_number?: string;
|
|
component_name?: string;
|
|
used_in_products?: string;
|
|
}
|
|
|
|
interface LineItem {
|
|
component_id: number;
|
|
component_name: string;
|
|
part_number: string;
|
|
quantity: number;
|
|
}
|
|
|
|
export default function SupplierPackingTab() {
|
|
const { data: supplierPackingLists, mutate } = useSWR<SupplierPackingList[]>(
|
|
"/api/supplier-packing-lists",
|
|
fetcher
|
|
);
|
|
const { data: suppliers } = useSWR<Supplier[]>("/api/suppliers", fetcher);
|
|
|
|
const [search, setSearch] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [viewTarget, setViewTarget] = useState<number | null>(null);
|
|
const [viewData, setViewData] = useState<
|
|
(SupplierPackingList & { items: SupplierPackingItem[] }) | null
|
|
>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<SupplierPackingList | null>(null);
|
|
|
|
// Create form state
|
|
const [selectedSupplierId, setSelectedSupplierId] = useState("");
|
|
const [packingNumber, setPackingNumber] = useState("");
|
|
const [lineItems, setLineItems] = useState<LineItem[]>([]);
|
|
const [scanStatus, setScanStatus] = useState<string | null>(null);
|
|
const [notes, setNotes] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const scanBufferRef = useRef("");
|
|
const scanTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const scanInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Generate packing number preview
|
|
useEffect(() => {
|
|
if (!selectedSupplierId || !suppliers) {
|
|
setPackingNumber("");
|
|
return;
|
|
}
|
|
const supplier = suppliers.find((s) => String(s.id) === selectedSupplierId);
|
|
if (!supplier) return;
|
|
const supplierName = supplier.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}`;
|
|
const existing = (supplierPackingLists || []).filter(
|
|
(pl) =>
|
|
pl.packing_number.startsWith(datePrefix) &&
|
|
pl.packing_number.endsWith(`-${supplierName}`)
|
|
).length;
|
|
const revision = String(existing + 1).padStart(2, "0");
|
|
setPackingNumber(`${datePrefix}-${revision}-${supplierName}`);
|
|
}, [selectedSupplierId, suppliers, supplierPackingLists]);
|
|
|
|
// Fetch detail for view
|
|
useEffect(() => {
|
|
if (!viewTarget) {
|
|
setViewData(null);
|
|
return;
|
|
}
|
|
fetch(`/api/supplier-packing-lists/${viewTarget}`)
|
|
.then((r) => r.json())
|
|
.then(setViewData);
|
|
}, [viewTarget]);
|
|
|
|
function resetCreateForm() {
|
|
setSelectedSupplierId("");
|
|
setLineItems([]);
|
|
scanBufferRef.current = "";
|
|
setScanStatus(null);
|
|
setNotes("");
|
|
setCreating(false);
|
|
}
|
|
|
|
// Process the scan buffer: parse tab-separated QR data and look up the component
|
|
const processScanBuffer = useCallback(async () => {
|
|
const raw = scanBufferRef.current;
|
|
scanBufferRef.current = "";
|
|
if (scanInputRef.current) scanInputRef.current.value = "";
|
|
|
|
const parts = raw.split("\t");
|
|
if (parts.length < 3) {
|
|
setScanStatus("Incomplete scan - expected 3 tab-separated fields");
|
|
return;
|
|
}
|
|
|
|
const partNumber = parts[1]?.trim();
|
|
const qty = Number(parts[2]?.trim());
|
|
|
|
if (!partNumber || !qty || qty <= 0) {
|
|
setScanStatus("Invalid scan data");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/components?search=${encodeURIComponent(partNumber)}`);
|
|
const data = await res.json();
|
|
const comp = Array.isArray(data)
|
|
? data.find((c: ComponentType) => c.part_number === partNumber)
|
|
: null;
|
|
|
|
if (!comp) {
|
|
setScanStatus(`Component "${partNumber}" not found`);
|
|
toast.error(`Component "${partNumber}" not found in database`);
|
|
return;
|
|
}
|
|
|
|
setLineItems((prev) => [
|
|
...prev,
|
|
{
|
|
component_id: comp.id,
|
|
component_name: comp.name,
|
|
part_number: comp.part_number,
|
|
quantity: qty,
|
|
},
|
|
]);
|
|
setScanStatus(`Added ${partNumber} x${qty}`);
|
|
toast.success(`Added ${comp.part_number} - ${comp.name} (x${qty})`);
|
|
} catch {
|
|
setScanStatus("Error looking up component");
|
|
toast.error("Error looking up component");
|
|
}
|
|
|
|
// Re-focus the scan input for the next scan
|
|
scanInputRef.current?.focus();
|
|
}, [lineItems]);
|
|
|
|
// Handle all keydown events: build buffer from all keys, prevent Tab from moving focus
|
|
function handleScanKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
scanBufferRef.current += "\t";
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
|
|
processScanBuffer();
|
|
return;
|
|
} else if (e.key === "Backspace") {
|
|
scanBufferRef.current = scanBufferRef.current.slice(0, -1);
|
|
} else if (e.key.length === 1) {
|
|
// Regular printable character
|
|
scanBufferRef.current += e.key;
|
|
} else {
|
|
return; // Ignore other special keys
|
|
}
|
|
|
|
// Update the visible input to show non-tab content for user feedback
|
|
if (scanInputRef.current) {
|
|
scanInputRef.current.value = scanBufferRef.current.replace(/\t/g, " | ");
|
|
}
|
|
|
|
// Debounce: process 200ms after last keystroke (scanners type fast)
|
|
if (scanTimerRef.current) clearTimeout(scanTimerRef.current);
|
|
scanTimerRef.current = setTimeout(() => {
|
|
processScanBuffer();
|
|
}, 200);
|
|
|
|
// Prevent default for all keys so the input doesn't double-type
|
|
if (e.key.length === 1) e.preventDefault();
|
|
}
|
|
|
|
function removeLineItem(componentId: number) {
|
|
setLineItems((prev) => prev.filter((li) => li.component_id !== componentId));
|
|
}
|
|
|
|
async function handleCreate() {
|
|
if (!selectedSupplierId || !lineItems.length) {
|
|
toast.error("Select a supplier and add at least one component");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
const res = await fetch("/api/supplier-packing-lists", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
supplier_id: Number(selectedSupplierId),
|
|
items: lineItems.map((li) => ({
|
|
component_id: li.component_id,
|
|
quantity: li.quantity,
|
|
})),
|
|
notes: notes || null,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed to create supplier packing list");
|
|
}
|
|
toast.success("Supplier packing list created");
|
|
mutate();
|
|
setCreateOpen(false);
|
|
resetCreateForm();
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error ? err.message : "Error creating supplier packing list"
|
|
);
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
const res = await fetch(`/api/supplier-packing-lists/${deleteTarget.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed to delete");
|
|
}
|
|
toast.success("Supplier packing list deleted");
|
|
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/supplier-packing-lists/${plId}`);
|
|
const data = await res.json();
|
|
const { generateSupplierPackingPdf } = await import(
|
|
"@/lib/generate-supplier-packing-pdf"
|
|
);
|
|
const doc = generateSupplierPackingPdf(data);
|
|
doc.save(`${data.packing_number}.pdf`);
|
|
} catch {
|
|
toast.error("Failed to generate PDF");
|
|
}
|
|
}, []);
|
|
|
|
const filtered = (supplierPackingLists || []).filter((pl) => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return (
|
|
pl.packing_number.toLowerCase().includes(s) ||
|
|
(pl.supplier_name || "").toLowerCase().includes(s)
|
|
);
|
|
});
|
|
|
|
const { sorted: sortedFiltered, sort, toggleSort } = useTableSort(filtered);
|
|
|
|
const totalItems = filtered.reduce(
|
|
(s, pl) => s + Number(pl.item_count || 0),
|
|
0
|
|
);
|
|
const totalQty = filtered.reduce(
|
|
(s, pl) => s + Number(pl.total_quantity || 0),
|
|
0
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-6 flex justify-end">
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New Supplier 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">
|
|
<Building2 className="h-8 w-8 text-primary" />
|
|
<div>
|
|
<p className="text-2xl font-bold">{filtered.length}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Supplier Packing Lists
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<Cpu className="h-8 w-8 text-accent" />
|
|
<div>
|
|
<p className="text-2xl font-bold">{totalItems}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Total Component Lines
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-3">
|
|
<Package className="h-8 w-8 text-warning" />
|
|
<div>
|
|
<p className="text-2xl font-bold">{totalQty}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Total Units
|
|
</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 or supplier..."
|
|
className="pl-10"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Building2 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="supplier_name" label="Supplier" sort={sort} onToggle={toggleSort} />
|
|
<SortableTableHead column="component_count" label="Components" sort={sort} onToggle={toggleSort} className="text-right" />
|
|
<SortableTableHead column="total_quantity" label="Total Qty" 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={6}
|
|
className="text-center py-12 text-muted-foreground"
|
|
>
|
|
No supplier 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>
|
|
<div>
|
|
<span className="font-medium text-sm">
|
|
{pl.supplier_name}
|
|
</span>
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
({pl.supplier_code})
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono font-medium">
|
|
{Number(pl.item_count || 0)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono font-medium">
|
|
{Number(pl.total_quantity || 0)}
|
|
</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>
|
|
|
|
{/* Create 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">
|
|
<Building2 className="h-5 w-5 text-primary" />
|
|
New Supplier 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 supplier..."}
|
|
readOnly
|
|
className="font-mono bg-muted text-muted-foreground"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Auto-generated: PL-EACH-YYYY_MM_DD-revision-SUPPLIER
|
|
</p>
|
|
</div>
|
|
|
|
{/* Select Supplier */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label>Supplier</Label>
|
|
<Select
|
|
value={selectedSupplierId}
|
|
onValueChange={setSelectedSupplierId}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a supplier..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(suppliers || []).map((s) => (
|
|
<SelectItem key={s.id} value={String(s.id)}>
|
|
{s.name} ({s.code})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Scan Components */}
|
|
{selectedSupplierId && (
|
|
<div className="flex flex-col gap-3">
|
|
<Label>Scan Component QR Code</Label>
|
|
<Input
|
|
ref={scanInputRef}
|
|
autoFocus
|
|
placeholder="Scan QR code here..."
|
|
className="font-mono"
|
|
onKeyDown={handleScanKeyDown}
|
|
onChange={(e) => e.preventDefault()}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Format: Component	PartNumber	Quantity (tab-separated via QR scan)
|
|
</p>
|
|
{scanStatus && (
|
|
<p className="text-xs font-medium text-primary">{scanStatus}</p>
|
|
)}
|
|
|
|
{lineItems.length > 0 && (
|
|
<div className="rounded-md border border-border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10">#</TableHead>
|
|
<TableHead>Part Number</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead className="text-right">Qty</TableHead>
|
|
<TableHead className="w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{lineItems.map((li, i) => (
|
|
<TableRow key={li.component_id}>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{i + 1}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{li.part_number}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{li.component_name}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono font-medium">
|
|
{li.quantity}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-destructive"
|
|
onClick={() =>
|
|
removeLineItem(li.component_id)
|
|
}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{lineItems.length > 0 && (
|
|
<div className="flex justify-end text-sm text-muted-foreground">
|
|
{lineItems.length} component(s),{" "}
|
|
{lineItems.reduce((s, li) => s + li.quantity, 0)} total units
|
|
</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 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 || !lineItems.length || !selectedSupplierId}
|
|
onClick={handleCreate}
|
|
>
|
|
{creating
|
|
? "Creating..."
|
|
: `Create Packing List (${lineItems.length} components)`}
|
|
</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">Supplier</span>
|
|
<p className="font-medium">{viewData.supplier_name}</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">
|
|
Components ({viewData.items?.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>Part Number</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead className="text-right">Qty</TableHead>
|
|
<TableHead>Used In Products</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(viewData.items || []).map(
|
|
(item: SupplierPackingItem, i: number) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{i + 1}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{item.part_number}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{item.component_name}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono font-medium">
|
|
{Number(item.quantity)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{item.used_in_products || "-"}
|
|
</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 Supplier Packing List?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will delete packing list{" "}
|
|
<strong>{deleteTarget?.packing_number}</strong> and all its
|
|
component line items. 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>
|
|
</>
|
|
);
|
|
}
|