update admin pages for models and parts

This commit is contained in:
2025-11-25 09:18:59 +00:00
parent 5412abe0fe
commit 0a938d5a6c
31 changed files with 3215 additions and 522 deletions
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
export default function AdminHeader() {
const pathname = usePathname();
// Convert pathname into readable title:
const title =
pathname.replace("/admin", "").replace("/", " ").trim() || "Dashboard";
return (
<header className="h-16 bg-white border-b flex items-center px-6">
<h1 className="text-xl font-semibold capitalize">
{title === "" ? "Dashboard" : title}
</h1>
</header>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export default function AdminSidebar() {
const pathname = usePathname();
const nav = [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin/models", label: "Models" },
{ href: "/admin/parts", label: "Parts" },
];
return (
<aside className="w-64 bg-white border-r min-h-screen p-4 flex flex-col">
<div className="text-2xl font-bold mb-8 pl-2">RC Admin</div>
<nav className="space-y-2">
{nav.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"block px-3 py-2 rounded text-sm font-medium",
active
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex-1" />
<div className="text-xs text-gray-400 pl-3 pb-4">
RC Compatibility Admin
</div>
</aside>
);
}
+11 -24
View File
@@ -1,33 +1,20 @@
import React from "react";
import Link from "next/link";
import "../globals.css";
import AdminSidebar from "./components/AdminSidebar";
import AdminHeader from "./components/AdminHeader";
export default function AdminLayout({ children }) {
return (
<div className="min-h-screen flex bg-gray-100">
<aside className="w-64 bg-white shadow-md p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-900">Admin</h2>
{/* SIDEBAR */}
<AdminSidebar />
<nav className="space-y-4">
<Link href="/admin" className="block hover:text-black text-gray-900">
Dashboard
</Link>
<Link
href="/admin/models"
className="block hover:text-gray-300 text-gray-900"
>
Models
</Link>
<Link
href="/admin/parts"
className="block hover:text-gray-300 text-gray-900"
>
Parts
</Link>
</nav>
</aside>
{/* MAIN COLUMN */}
<div className="flex-1 flex flex-col">
{/* TOP HEADER */}
<AdminHeader />
<main className="flex-1 p-10 admin-text text-gray-900">{children}</main>
{/* PAGE CONTENT */}
<main className="p-6">{children}</main>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddPartToModelPage({ params }) {
const { id: modelId } = use(params); // React 19 unwrap
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedPart, setSelectedPart] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// Search parts live
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
async function save() {
if (!selectedPart) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: selectedPart.id,
model_id: modelId,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/models/${modelId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH */}
<div className="space-y-2">
<Label>Search Parts</Label>
<Input
placeholder="Enter part name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedPart && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPart(p)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{p.name}</div>
<div className="text-sm text-gray-500">
{p.manufacturer || "-"} {p.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED PART CARD */}
{selectedPart && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedPart.name}</div>
<div className="text-sm text-gray-500">
{selectedPart.manufacturer || "-"} {" "}
{selectedPart.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedPart(null)}
>
Change
</Button>
</div>
)}
{/* FIT TYPE + NOTES */}
{selectedPart && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose Fit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[100px]"
/>
</div>
{/* SAVE */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+288 -44
View File
@@ -1,55 +1,299 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer";
import { supabaseBrowser } from "@/lib/supabaseClient";
export default async function ModelDetail({ params }) {
const { id } = params;
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
const { data: model } = await supabaseServer
.from("models")
.select("*")
.eq("id", id)
.single();
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const { data: compat } = await supabaseServer
.from("parts_compatible_models")
.select("*, parts(*)")
.eq("model_id", id);
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function ModelDetailPage({ params }) {
const { id } = use(params); // React 19: unwrap params
const router = useRouter();
const [model, setModel] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// Load model + compatibility
useEffect(() => {
async function load() {
setLoading(true);
const { data: modelData } = await supabaseBrowser
.from("models")
.select("*")
.eq("id", id)
.single();
setModel(modelData || null);
const { data: compatData } = await supabaseBrowser
.from("parts_compatible_models")
.select("*, parts(*)")
.eq("model_id", id);
setCompat(compatData || []);
setLoading(false);
}
load();
}, [id]);
// Save model changes
async function saveModel() {
if (!model) return;
setSaving(true);
let metadataObj = null;
if (typeof model.metadata === "string" && model.metadata.trim() !== "") {
try {
metadataObj = JSON.parse(model.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
const updatePayload = {
name: model.name,
category: model.category || null,
manufacturer: model.manufacturer || null,
year: model.year || null,
description: model.description || null,
metadata: metadataObj,
};
await supabaseBrowser.from("models").update(updatePayload).eq("id", id);
setSaving(false);
}
async function deleteModel() {
await supabaseBrowser.from("models").delete().eq("id", id);
router.push("/admin/models");
}
async function removeCompatibility(compatId) {
await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
}
// PREVENT NULL CRASHES
if (loading) return <p className="p-6">Loading model</p>;
if (!model) return <p className="p-6 text-red-600">Model not found.</p>;
return (
<div>
<h1 className="text-3xl font-bold mb-4">{model?.name}</h1>
<div className="space-y-10">
{/* MODEL EDIT CARD */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Model</CardTitle>
</CardHeader>
<div className="bg-white p-4 shadow rounded mb-6">
<div className="text-gray-700">Brand: {model?.brand}</div>
<div className="text-gray-700">Scale: {model?.scale}</div>
<div className="text-gray-700">Chassis: {model?.chassis_type}</div>
</div>
<h2 className="text-2xl font-bold mb-2">Compatible Parts</h2>
<Link
href={`/admin/models/${id}/add-part`}
className="inline-block mb-4 px-4 py-2 bg-black text-white rounded"
>
+ Add Compatible Part
</Link>
<div className="bg-white shadow rounded divide-y">
{compat?.map((c) => (
<div key={c.id} className="p-4 flex justify-between">
<div>
<div className="font-semibold">{c.parts.name}</div>
<div className="text-sm text-gray-500">{c.fit_type}</div>
</div>
<Link
href={`/admin/parts/${c.parts.id}`}
className="text-blue-600 underline"
>
View Part
</Link>
<CardContent className="space-y-6">
{/* NAME */}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={model?.name || ""}
onChange={(e) => setModel({ ...model, name: e.target.value })}
/>
</div>
))}
</div>
{/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={model?.manufacturer || ""}
onChange={(e) =>
setModel({ ...model, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={model?.category || ""}
onChange={(e) =>
setModel({ ...model, category: e.target.value })
}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={model?.year || ""}
onChange={(e) => setModel({ ...model, year: e.target.value })}
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={model?.description || ""}
onChange={(e) =>
setModel({ ...model, description: e.target.value })
}
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[120px]"
value={
typeof model.metadata === "object"
? JSON.stringify(model.metadata, null, 2)
: model?.metadata || ""
}
onChange={(e) => setModel({ ...model, metadata: e.target.value })}
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex items-center justify-between pt-4">
<Button onClick={saveModel} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
{/* DELETE WITH DIALOG */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Model</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This action cannot be undone. This will permanently delete the
model and all compatibility links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deleteModel}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* COMPATIBILITY TABLE */}
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Compatible Parts</CardTitle>
<Button asChild>
<Link href={`/admin/models/${id}/add-part`}>
+ Add Compatible Part
</Link>
</Button>
</CardHeader>
<Separator />
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead>Fit Type</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compat.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.parts?.name}</TableCell>
<TableCell>
<Badge variant="secondary">
{c.fit_type?.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>{c.notes || "-"}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No compatible parts found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreateModelPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
year: "",
description: "",
metadata: "",
});
async function save() {
let metadataObj = null;
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON");
return;
}
}
const { error } = await supabaseBrowser.from("models").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
year: form.year || null,
description: form.description || null,
metadata: metadataObj,
});
if (!error) {
router.push("/admin/models");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={form.year}
onChange={(e) => setForm({ ...form, year: e.target.value })}
/>
</div>
</div>
<div>
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
/>
</div>
<div className="flex justify-between">
<Button onClick={save}>Create Model</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/models")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { useState } from "react";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { useRouter } from "next/navigation";
export default function NewModelPage() {
const [form, setForm] = useState({
name: "",
brand: "",
scale: "",
chassis_type: "",
notes: "",
});
const router = useRouter();
async function save() {
const { error } = await supabaseBrowser.from("models").insert(form);
if (!error) router.push("/admin/models");
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Add New Model</h1>
<div className="space-y-4 max-w-lg">
{Object.entries(form).map(([key, value]) => (
<div key={key}>
<label className="block mb-1 capitalize">{key.replace("_", " ")}</label>
<input
className="w-full p-2 border rounded"
value={value}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</div>
))}
<button
onClick={save}
className="px-4 py-2 bg-black text-white rounded"
>
Save
</button>
</div>
</div>
);
}
+203 -35
View File
@@ -1,43 +1,211 @@
import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer";
"use client";
export default async function ModelsPage() {
const { data: models } = await supabaseServer
.from("models")
.select("*")
.order("name");
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function ModelsListPage() {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
// Search / Filter / Sort states
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc | newest | oldest
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("models")
.select("*")
.order("name", { ascending: true });
setModels(data || []);
setLoading(false);
}
load();
}, []);
// ----------------------------------------------
// 🎯 FILTERING + SEARCH + SORTING (client-side)
// ----------------------------------------------
const filteredModels = useMemo(() => {
let list = [...models];
// SEARCH
if (search.trim()) {
list = list.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((m) => m.manufacturer === manufacturerFilter);
}
// SORTING
switch (sort) {
case "asc":
list.sort((a, b) => a.name.localeCompare(b.name));
break;
case "desc":
list.sort((a, b) => b.name.localeCompare(a.name));
break;
case "newest":
list.sort((a, b) => (b.year || 0) - (a.year || 0));
break;
case "oldest":
list.sort((a, b) => (a.year || 0) - (b.year || 0));
break;
}
return list;
}, [models, search, manufacturerFilter, sort]);
// SKELETON
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
}
// Collect manufacturers for filter dropdown
const manufacturers = Array.from(
new Set(models.map((m) => m.manufacturer).filter(Boolean))
);
return (
<div>
<div className="flex justify-between mb-6">
<h1 className="text-3xl font-bold">Models</h1>
<Link
href="/admin/models/new"
className="px-4 py-2 bg-black text-white rounded"
>
+ Add Model
</Link>
</div>
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Models</CardTitle>
{(!models || models.length === 0) && (
<p className="text-gray-700">
No models found yet. Click &ldquo;Add Model&rdquo; to create your
first one.
</p>
)}
<Button asChild>
<Link href="/admin/models/create">+ New Model</Link>
</Button>
</CardHeader>
<div className="bg-white shadow rounded-lg divide-y">
{models?.map((m) => (
<Link
key={m.id}
href={`/admin/models/${m.id}`}
className="block p-4 hover:bg-gray-50"
<CardContent className="space-y-4">
{/* SEARCH + FILTERS + SORT BAR */}
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* SEARCH */}
<Input
placeholder="Search models…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{/* MANUFACTURER FILTER */}
<Select
value={manufacturerFilter}
onValueChange={setManufacturerFilter}
>
<div className="font-semibold">{m.name}</div>
<div className="text-sm text-gray-500">{m.brand}</div>
</Link>
))}
</div>
</div>
<SelectTrigger className="w-48">
<SelectValue placeholder="Manufacturer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
<SelectItem value="newest">Year (Newest)</SelectItem>
<SelectItem value="oldest">Year (Oldest)</SelectItem>
</SelectContent>
</Select>
</div>
{/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>Year</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredModels.map((m) => (
<TableRow key={m.id}>
<TableCell>{m.name}</TableCell>
<TableCell>{m.manufacturer || "-"}</TableCell>
<TableCell>{m.category || "-"}</TableCell>
<TableCell>{m.year || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/models/${m.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredModels.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No models found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddModelToPartPage({ params }) {
const { id: partId } = use(params); // React 19 unwrapping
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedModel, setSelectedModel] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// 🔍 Live search for models
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("models")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
// 💾 Save compatibility entry
async function save() {
if (!selectedModel) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: partId,
model_id: selectedModel.id,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/parts/${partId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH FIELD */}
<div className="space-y-2">
<Label>Search Models</Label>
<Input
placeholder="Enter model name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedModel && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((m) => (
<button
key={m.id}
onClick={() => setSelectedModel(m)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{m.name}</div>
<div className="text-sm text-gray-500">
{m.manufacturer || "-"} {m.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED MODEL CARD */}
{selectedModel && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedModel.name}</div>
<div className="text-sm text-gray-500">
{selectedModel.manufacturer || "-"} {" "}
{selectedModel.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedModel(null)}
>
Change
</Button>
</div>
)}
{/* FIT-TYPE + NOTES */}
{selectedModel && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose fit type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
className="min-h-[100px]"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* SAVE BUTTON */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
{/* CANCEL BUTTON */}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+226 -155
View File
@@ -1,221 +1,292 @@
"use client";
import { useEffect, useState } from "react";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
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 { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PartDetailPage({ params }) {
const { id } = params;
const { id } = use(params); // React 19 unwrap
const router = useRouter();
const [part, setPart] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [form, setForm] = useState({
name: "",
manufacturer: "",
sku: "",
category: "",
description: "",
metadata: "",
});
const [deleteOpen, setDeleteOpen] = useState(false);
// Load part data + compatibility
useEffect(() => {
async function fetchPart() {
async function load() {
setLoading(true);
setErrorMsg("");
const { data, error } = await supabaseBrowser
const { data: partData } = await supabaseBrowser
.from("parts")
.select("*")
.eq("id", id)
.single();
if (error) {
console.error(error);
setErrorMsg("Failed to load part.");
setLoading(false);
return;
}
setPart(partData || null);
setForm({
name: data.name || "",
manufacturer: data.manufacturer || "",
sku: data.sku || "",
category: data.category || "",
description: data.description || "",
metadata: data.metadata ? JSON.stringify(data.metadata, null, 2) : "",
});
const { data: compatData } = await supabaseBrowser
.from("parts_compatible_models")
.select("*, models(*)")
.eq("part_id", id);
setCompat(compatData || []);
setLoading(false);
}
fetchPart();
load();
}, [id]);
function updateField(field, value) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSave() {
// Save part edits
async function savePart() {
if (!part) return;
setSaving(true);
setErrorMsg("");
let metadata = null;
if (form.metadata.trim()) {
let metadataObj = null;
if (typeof part.metadata === "string" && part.metadata.trim() !== "") {
try {
metadata = JSON.parse(form.metadata);
} catch (err) {
metadataObj = JSON.parse(part.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
setErrorMsg("Metadata must be valid JSON.");
return;
}
}
const payload = {
name: form.name.trim(),
manufacturer: form.manufacturer || null,
sku: form.sku || null,
category: form.category || null,
description: form.description || null,
metadata,
const updatePayload = {
name: part.name,
manufacturer: part.manufacturer || null,
sku: part.sku || null,
category: part.category || null,
description: part.description || null,
metadata: metadataObj,
};
const { error } = await supabaseBrowser
.from("parts")
.update(payload)
.eq("id", id);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to save changes.");
setSaving(false);
return;
}
await supabaseBrowser.from("parts").update(updatePayload).eq("id", id);
setSaving(false);
}
async function handleDelete() {
const ok = window.confirm(
"Are you sure you want to delete this part? This cannot be undone."
);
if (!ok) return;
setDeleting(true);
setErrorMsg("");
const { error } = await supabaseBrowser.from("parts").delete().eq("id", id);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to delete part.");
setDeleting(false);
return;
}
async function deletePart() {
await supabaseBrowser.from("parts").delete().eq("id", id);
router.push("/admin/parts");
}
if (loading) {
return <p>Loading part...</p>;
async function removeCompatibility(compatId) {
await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
}
// Prevent render crashes
if (loading) return <p className="p-6">Loading part</p>;
if (!part) return <p className="p-6 text-red-600">Part not found.</p>;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Edit Part</h1>
<div className="space-y-10">
{/* PART EDIT FORM */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Part</CardTitle>
</CardHeader>
<div className="space-y-4 max-w-2xl bg-white p-6 rounded shadow">
<div>
<label className="block mb-1 font-medium">Name *</label>
<input
className="w-full p-2 border rounded"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
/>
</div>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={part?.name || ""}
onChange={(e) => setPart({ ...part, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={part?.manufacturer || ""}
onChange={(e) =>
setPart({ ...part, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={part?.category || ""}
onChange={(e) => setPart({ ...part, category: e.target.value })}
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={part?.sku || ""}
onChange={(e) => setPart({ ...part, sku: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block mb-1 font-medium">Manufacturer</label>
<input
className="w-full p-2 border rounded"
value={form.manufacturer}
onChange={(e) => updateField("manufacturer", e.target.value)}
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={part?.description || ""}
onChange={(e) =>
setPart({ ...part, description: e.target.value })
}
/>
</div>
<div>
<label className="block mb-1 font-medium">SKU</label>
<input
className="w-full p-2 border rounded"
value={form.sku}
onChange={(e) => updateField("sku", e.target.value)}
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={
typeof part.metadata === "object"
? JSON.stringify(part.metadata, null, 2)
: part?.metadata || ""
}
onChange={(e) => setPart({ ...part, metadata: e.target.value })}
/>
</div>
<div>
<label className="block mb-1 font-medium">Category</label>
<input
className="w-full p-2 border rounded"
value={form.category}
onChange={(e) => updateField("category", e.target.value)}
/>
{/* SAVE + DELETE */}
<div className="flex items-center justify-between pt-4">
<Button onClick={savePart} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Part</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This will permanently delete the part and all compatibility
links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deletePart}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
<div>
<label className="block mb-1 font-medium">Description</label>
<textarea
className="w-full p-2 border rounded min-h-[80px]"
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
{/* COMPATIBLE MODELS TABLE */}
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Compatible Models</CardTitle>
<div>
<label className="block mb-1 font-medium">
Metadata (JSON){" "}
<span className="text-xs text-gray-500">(optional)</span>
</label>
<textarea
className="w-full p-2 border rounded font-mono text-sm min-h-[120px]"
value={form.metadata}
onChange={(e) => updateField("metadata", e.target.value)}
/>
</div>
<Button asChild>
<Link href={`/admin/parts/${id}/add-model`}>
+ Add Compatible Model
</Link>
</Button>
</CardHeader>
{errorMsg && <p className="text-red-600 text-sm">{errorMsg}</p>}
<Separator />
<div className="flex gap-3 items-center">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-4 py-2 bg-black text-white rounded disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Fit Type</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<button
type="button"
onClick={() => router.push("/admin/parts")}
className="px-4 py-2 border rounded"
>
Back to Parts
</button>
<TableBody>
{compat.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.models?.name}</TableCell>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="ml-auto px-4 py-2 bg-red-600 text-white rounded disabled:opacity-50"
>
{deleting ? "Deleting..." : "Delete Part"}
</button>
</div>
</div>
<TableCell>
<Badge variant="secondary">
{c.fit_type?.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>{c.notes || "-"}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No models linked to this part.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreatePartPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
sku: "",
description: "",
metadata: "",
});
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
let metadataObj = null;
// Parse metadata if provided
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
// Insert into Supabase
const { error } = await supabaseBrowser.from("parts").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
sku: form.sku || null,
description: form.description || null,
metadata: metadataObj,
});
setSaving(false);
if (!error) {
router.push("/admin/parts");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* NAME */}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Tamiya Hotshot Gearbox"
/>
</div>
{/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
placeholder="e.g., Tamiya"
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
placeholder="e.g., drivetrain"
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={form.sku}
onChange={(e) => setForm({ ...form, sku: e.target.value })}
placeholder="e.g., TAM-198055"
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional part description..."
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
placeholder='{ "color": "red", "material": "plastic" }'
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex justify-between pt-4">
<Button onClick={save} disabled={saving}>
{saving ? "Creating…" : "Create Part"}
</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/parts")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
-151
View File
@@ -1,151 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
export default function NewPartPage() {
const [form, setForm] = useState({
name: "",
manufacturer: "",
sku: "",
category: "",
description: "",
metadata: "", // JSON as text
});
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const router = useRouter();
function updateField(field, value) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSave() {
setSaving(true);
setErrorMsg("");
let metadata = null;
if (form.metadata.trim()) {
try {
metadata = JSON.parse(form.metadata);
} catch (err) {
setSaving(false);
setErrorMsg("Metadata must be valid JSON.");
return;
}
}
const payload = {
name: form.name.trim(),
manufacturer: form.manufacturer || null,
sku: form.sku || null,
category: form.category || null,
description: form.description || null,
metadata,
};
const { error } = await supabaseBrowser.from("parts").insert(payload);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to save part.");
setSaving(false);
return;
}
router.push("/admin/parts");
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Add New Part</h1>
<div className="space-y-4 max-w-2xl bg-white p-6 rounded shadow">
<div>
<label className="block mb-1 font-medium">Name *</label>
<input
className="w-full p-2 border rounded"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="e.g. Alloy Suspension Arm Set"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block mb-1 font-medium">Manufacturer</label>
<input
className="w-full p-2 border rounded"
value={form.manufacturer}
onChange={(e) => updateField("manufacturer", e.target.value)}
/>
</div>
<div>
<label className="block mb-1 font-medium">SKU</label>
<input
className="w-full p-2 border rounded"
value={form.sku}
onChange={(e) => updateField("sku", e.target.value)}
placeholder="e.g. 53620"
/>
</div>
<div>
<label className="block mb-1 font-medium">Category</label>
<input
className="w-full p-2 border rounded"
value={form.category}
onChange={(e) => updateField("category", e.target.value)}
placeholder="e.g. suspension, drivetrain"
/>
</div>
</div>
<div>
<label className="block mb-1 font-medium">Description</label>
<textarea
className="w-full p-2 border rounded min-h-[80px]"
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
<div>
<label className="block mb-1 font-medium">
Metadata (JSON){" "}
<span className="text-xs text-gray-500">(optional)</span>
</label>
<textarea
className="w-full p-2 border rounded font-mono text-sm min-h-[100px]"
placeholder='e.g. { "material": "aluminium", "color": "blue" }'
value={form.metadata}
onChange={(e) => updateField("metadata", e.target.value)}
/>
</div>
{errorMsg && <p className="text-red-600 text-sm">{errorMsg}</p>}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-4 py-2 bg-black text-white rounded disabled:opacity-50"
>
{saving ? "Saving..." : "Save Part"}
</button>
<button
type="button"
onClick={() => router.push("/admin/parts")}
className="px-4 py-2 border rounded"
>
Cancel
</button>
</div>
</div>
</div>
);
}
+223 -47
View File
@@ -1,60 +1,236 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer";
import { supabaseBrowser } from "@/lib/supabaseClient";
export default async function PartsPage() {
const { data: parts, error } = await supabaseServer
.from("parts")
.select("*")
.order("name", { ascending: true });
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
console.log("here");
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
if (error) {
console.error("Error loading parts:", error);
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function PartsListPage() {
const [parts, setParts] = useState([]);
const [loading, setLoading] = useState(true);
// UI state: Search, filters, sorting
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.order("name", { ascending: true });
setParts(data || []);
setLoading(false);
}
load();
}, []);
// --------------------------------------------------------
// 🎯 FILTER + SEARCH + SORT (client-side, fast)
// --------------------------------------------------------
const filteredParts = useMemo(() => {
let list = [...parts];
// SEARCH
if (search.trim()) {
list = list.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((p) => p.manufacturer === manufacturerFilter);
}
// CATEGORY FILTER
if (categoryFilter !== "all") {
list = list.filter((p) => p.category === categoryFilter);
}
// SORTING
if (sort === "asc") {
list.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort === "desc") {
list.sort((a, b) => b.name.localeCompare(a.name));
}
return list;
}, [parts, search, manufacturerFilter, categoryFilter, sort]);
// --------------------------------------------------------
// 🦴 SKELETON LOADING
// --------------------------------------------------------
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
}
// Collect filter options
const manufacturers = Array.from(
new Set(parts.map((p) => p.manufacturer).filter(Boolean))
);
const categories = Array.from(
new Set(parts.map((p) => p.category).filter(Boolean))
);
// --------------------------------------------------------
// 🌟 RENDER UI
// --------------------------------------------------------
return (
<div>
<div className="flex justify-between mb-6 items-center">
<h1 className="text-3xl font-bold">Parts</h1>
<Link
href="/admin/parts/new"
className="px-4 py-2 bg-black text-white rounded hover:bg-gray-800"
>
+ Add Part
</Link>
</div>
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Parts</CardTitle>
{(!parts || parts.length === 0) && (
<p className="text-gray-700">
No parts found yet. Click &ldquo;Add Part&rdquo; to create your first
one.
</p>
)}
<Button asChild>
<Link href="/admin/parts/create">+ New Part</Link>
</Button>
</CardHeader>
{parts && parts.length > 0 && (
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="grid grid-cols-4 gap-4 px-4 py-2 border-b bg-gray-50 text-sm font-semibold">
<div>Name</div>
<div>Manufacturer</div>
<div>SKU</div>
<div>Category</div>
</div>
<CardContent className="space-y-4">
{/* SEARCH + FILTERS + SORT BAR */}
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* SEARCH */}
<Input
placeholder="Search parts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{parts.map((p) => (
<Link
key={p.id}
href={`/admin/parts/${p.id}`}
className="grid grid-cols-4 gap-4 px-4 py-3 border-b hover:bg-gray-50 text-sm text-gray-900"
>
<div className="font-semibold">{p.name}</div>
<div>{p.manufacturer || "-"}</div>
<div>{p.sku || "-"}</div>
<div>{p.category || "-"}</div>
</Link>
))}
{/* MANUFACTURER FILTER */}
<Select
value={manufacturerFilter}
onValueChange={setManufacturerFilter}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Manufacturer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* CATEGORY FILTER */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredParts.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.name}</TableCell>
<TableCell>{p.manufacturer || "-"}</TableCell>
<TableCell>{p.category || "-"}</TableCell>
<TableCell>{p.sku || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/parts/${p.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredParts.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No parts match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}