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,236 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Plus, Pencil, Trash2, ClipboardList } from "lucide-react";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { SortableTableHead } from "@/components/sortable-table-head";
|
||||
import { ProductDialog } from "@/components/product-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { Product } from "@/lib/types";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
draft: "bg-muted text-muted-foreground",
|
||||
active: "bg-accent/15 text-accent border-accent/30",
|
||||
deprecated: "bg-warning/15 text-warning border-warning/30",
|
||||
archived: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Product | null>(null);
|
||||
|
||||
const { data: products = [], mutate } = useSWR<Product[]>(
|
||||
"/api/products",
|
||||
fetcher
|
||||
);
|
||||
|
||||
const { sorted: sortedProducts, sort, toggleSort } = useTableSort(products);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (data: Partial<Product>) => {
|
||||
const url = editingProduct
|
||||
? `/api/products/${editingProduct.id}`
|
||||
: "/api/products";
|
||||
const method = editingProduct ? "PUT" : "POST";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to save");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
editingProduct ? "Product updated" : "Product created"
|
||||
);
|
||||
mutate();
|
||||
},
|
||||
[editingProduct, mutate]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
await fetch(`/api/products/${deleteTarget.id}`, { method: "DELETE" });
|
||||
toast.success("Product deleted");
|
||||
setDeleteTarget(null);
|
||||
mutate();
|
||||
}, [deleteTarget, mutate]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingProduct(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(prod: Product) {
|
||||
setEditingProduct(prod);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Products
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your products and their bill of materials
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableTableHead column="sku" label="SKU" sort={sort} onToggle={toggleSort} className="w-[120px]" />
|
||||
<SortableTableHead column="name" label="Name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableTableHead column="version" label="Version" sort={sort} onToggle={toggleSort} />
|
||||
<SortableTableHead column="status" label="Status" sort={sort} onToggle={toggleSort} />
|
||||
<SortableTableHead column="bom_count" label="BOM Items" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||
<SortableTableHead column="total_cost" label="BOM Cost" sort={sort} onToggle={toggleSort} className="text-right" />
|
||||
<TableHead className="w-[130px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
No products yet. Click "Add Product" to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedProducts.map((prod) => (
|
||||
<TableRow key={prod.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">
|
||||
{prod.sku}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{prod.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
v{prod.version}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={statusStyles[prod.status] || ""}
|
||||
>
|
||||
{prod.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{prod.bom_count || 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
{formatCurrency(Number(prod.total_cost || 0))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/bom/${prod.id}`}>
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">View BOM</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(prod)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(prod)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ProductDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
product={editingProduct}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Product</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>{deleteTarget?.name}</strong> ({deleteTarget?.sku})? This
|
||||
will also delete all associated BOM items.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user