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 })
|
.order('name', { ascending: true })
|
||||||
|
|
||||||
if (error) throw error
|
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
|
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'>[]) {
|
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user