chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user