feat: add full CRUD management for product categories in admin panel

This commit is contained in:
DanielS
2026-05-01 00:57:33 +02:00
parent 1dd2756e22
commit 826cf33183
4 changed files with 341 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { getCategories } from '@/lib/actions/products'
import { CategoryList } from '@/components/admin/category-list'
import { CategoryDialog } from '@/components/admin/category-dialog'
import { LayoutGrid, Plus } from 'lucide-react'
import { Suspense } from 'react'
export default async function AdminCategoriesPage() {
return (
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<LayoutGrid className="w-8 h-8 text-primary" />
Kategorien
</h2>
<p className="text-muted-foreground">
Verwalten Sie die Struktur Ihres Webshops.
</p>
</div>
<div className="flex items-center space-x-2">
<Suspense fallback={<div className="h-10 w-32 animate-pulse bg-white/5 rounded-md" />}>
<CreateCategoryAction />
</Suspense>
</div>
</div>
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
<CategoryDataWrapper />
</Suspense>
</div>
)
}
async function CategoryDataWrapper() {
const categories = await getCategories()
return <CategoryList initialCategories={categories} />
}
async function CreateCategoryAction() {
return (
<CategoryDialog>
<button className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
<Plus className="mr-2 h-4 w-4" /> Kategorie hinzufügen
</button>
</CategoryDialog>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { createCategory, updateCategory } from '@/lib/actions/products'
import { Category } from '@/lib/types'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Cloud, Utensils, HardDrive, Package } from 'lucide-react'
const categorySchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
icon: z.string().default('Package'),
})
type CategoryFormValues = z.infer<typeof categorySchema>
export function CategoryDialog({
children,
category
}: {
children: React.ReactNode,
category?: Category
}) {
const [open, setOpen] = useState(false)
const form = useForm<CategoryFormValues>({
resolver: zodResolver(categorySchema) as any,
defaultValues: {
name: category?.name || '',
description: category?.description || '',
icon: category?.icon || 'Package',
},
})
async function onSubmit(values: CategoryFormValues) {
try {
if (category) {
await updateCategory(category.id, values)
} else {
await createCategory(values)
}
setOpen(false)
form.reset()
} catch (error) {
console.error('Error saving category:', error)
}
}
const icons = [
{ name: 'Cloud', Icon: Cloud },
{ name: 'Utensils', Icon: Utensils },
{ name: 'HardDrive', Icon: HardDrive },
{ name: 'Package', Icon: Package },
]
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
<DialogHeader>
<DialogTitle>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
<DialogDescription>
Definieren Sie eine Kategorie für Ihre Produkte.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icon</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue placeholder="Icon wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{icons.map(({ name, Icon }) => (
<SelectItem key={name} value={name}>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
{category ? 'Aktualisieren' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { Category } from '@/lib/types'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteCategory } from '@/lib/actions/products'
import { useState } from 'react'
import { CategoryDialog } from './category-dialog'
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
const [categories, setCategories] = useState(initialCategories)
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
await deleteCategory(id)
setCategories(categories.filter(c => c.id !== id))
}
}
const getIcon = (name?: string | null) => {
switch (name) {
case 'Cloud': return <Cloud className="w-4 h-4" />
case 'Utensils': return <Utensils className="w-4 h-4" />
case 'HardDrive': return <HardDrive className="w-4 h-4" />
default: return <Package className="w-4 h-4" />
}
}
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Kategorienübersicht</CardTitle>
<CardDescription>
Verwalten Sie die Hauptkategorien für Ihre Produkte.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-muted-foreground">Icon</TableHead>
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Beschreibung</TableHead>
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((category) => (
<TableRow key={category.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell>
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
{getIcon(category.icon)}
</div>
</TableCell>
<TableCell className="font-medium">{category.name}</TableCell>
<TableCell className="text-muted-foreground">{category.description}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<CategoryDialog category={category}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CategoryDialog>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(category.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{categories.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-10 text-muted-foreground">
Keine Kategorien gefunden.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}

View File

@@ -23,9 +23,47 @@ export async function getCategories() {
.order('name', { ascending: true })
if (error) throw error
return data as Category[]
}
export async function createCategory(category: Omit<Category, 'id' | 'created_at'>) {
const supabase = await createClient()
const { data, error } = await supabase
.from('categories')
.insert([category])
.select()
.single()
if (error) throw error
revalidatePath('/admin/categories')
revalidatePath('/order')
return data
}
export async function updateCategory(id: string, category: Partial<Category>) {
const supabase = await createClient()
const { error } = await supabase
.from('categories')
.update(category)
.eq('id', id)
if (error) throw error
revalidatePath('/admin/categories')
revalidatePath('/order')
}
export async function deleteCategory(id: string) {
const supabase = await createClient()
const { error } = await supabase
.from('categories')
.delete()
.eq('id', id)
if (error) throw error
revalidatePath('/admin/categories')
revalidatePath('/order')
}
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) {
const supabase = await createClient()