chore: upload parte 1 - logica
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s

This commit is contained in:
2026-06-01 15:35:53 +01:00
parent a63625d228
commit 9c14a19a87
127 changed files with 22255 additions and 0 deletions
+772
View File
@@ -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&#9;PartNumber&#9;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>
</>
);
}