237 lines
8.1 KiB
TypeScript
237 lines
8.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|