feat: add full CRUD management for product categories in admin panel
This commit is contained in:
46
shop/app/admin/categories/page.tsx
Normal file
46
shop/app/admin/categories/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
shop/components/admin/category-dialog.tsx
Normal file
160
shop/components/admin/category-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
shop/components/admin/category-list.tsx
Normal file
97
shop/components/admin/category-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user