feat: implement product categories with tabbed filtering and admin configuration

This commit is contained in:
DanielS
2026-05-01 00:52:40 +02:00
parent 9c0164c4fe
commit 316d9510a8
9 changed files with 948 additions and 29 deletions

View File

@@ -29,25 +29,36 @@ import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
import { createProduct } from '@/lib/actions/products'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Category } from '@/lib/types'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const moduleSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
additional_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
is_required: z.boolean().default(false),
requirements: z.string().optional().default(''), // Will be converted to array
exclusions: z.string().optional().default(''), // Will be converted to array
})
const productSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
is_active: z.boolean().default(true),
modules: z.array(moduleSchema).default([]),
})
type ProductFormValues = z.infer<typeof productSchema>
export function CreateProductDialog({ children }: { children: React.ReactNode }) {
export function CreateProductDialog({ categories }: { categories: Category[] }) {
const [open, setOpen] = useState(false)
const form = useForm<ProductFormValues>({
@@ -56,6 +67,7 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
name: '',
description: '',
base_price: 0,
category_id: '',
is_active: true,
modules: [],
},
@@ -69,7 +81,12 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
async function onSubmit(values: ProductFormValues) {
try {
const { modules, ...productData } = values
await createProduct(productData, modules)
const formattedModules = modules.map(m => ({
...m,
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
}))
await createProduct(productData as any, formattedModules as any)
setOpen(false)
form.reset()
} catch (error) {
@@ -79,7 +96,11 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogTrigger asChild>
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Neues Produkt erstellen</DialogTitle>
@@ -121,6 +142,29 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
/>
</div>
<FormField
control={form.control}
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
@@ -148,7 +192,7 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
variant="outline"
size="sm"
className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({ name: '', additional_price: 0, is_required: false, description: '' })}
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })}
>
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
</Button>
@@ -182,7 +226,7 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
/>
<FormField
control={form.control}
name={`modules.${index}.additional_price`}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel>Aufpreis ()</FormLabel>
@@ -195,6 +239,35 @@ export function CreateProductDialog({ children }: { children: React.ReactNode })
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.requirements`}
render={({ field }) => (
<FormItem>
<FormLabel>Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field }) => (
<FormItem>
<FormLabel>Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-center space-x-2">
<FormField
control={form.control}

View File

@@ -49,9 +49,16 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
{products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium">
<div>
{product.name}
<p className="text-xs text-muted-foreground font-normal mt-1">{product.description}</p>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
{product.category && (
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
{product.category.name}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>

View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}