feat: implement logic engine for module dependencies and exclusions

This commit is contained in:
DanielS
2026-05-01 00:52:34 +02:00
parent a73cc387ec
commit 17d5a78467
3 changed files with 190 additions and 38 deletions

View File

@@ -10,13 +10,15 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, CreditCard, ShieldCheck } from 'lucide-react'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, CreditCard, ShieldCheck, Cloud, Utensils, HardDrive, Package } from 'lucide-react'
import { submitOrder } from '@/lib/actions/orders'
import { Category } from '@/lib/types'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
export function OrderWizard({ products, initialProfile }: { products: Product[], initialProfile: Profile | null }) {
export function OrderWizard({ products, categories, initialProfile }: { products: Product[], categories: Category[], initialProfile: Profile | null }) {
const [step, setStep] = useState(1)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(products[0] || null)
const [selectedModules, setSelectedModules] = useState<string[]>([])
const [activeCategory, setActiveCategory] = useState<string>(categories[0]?.id || 'all')
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
company_name: '',
vat_id: '',
@@ -27,12 +29,20 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
zip_code: ''
})
const filteredProducts = useMemo(() => {
if (activeCategory === 'all') return products
return products.filter(p => p.category_id === activeCategory)
}, [products, activeCategory])
const [selectedProduct, setSelectedProduct] = useState<Product | null>(filteredProducts[0] || null)
const [selectedModules, setSelectedModules] = useState<string[]>([])
const totalPrice = useMemo(() => {
if (!selectedProduct) return 0
let total = Number(selectedProduct.base_price)
selectedModules.forEach(modId => {
const mod = selectedProduct.modules?.find(m => m.id === modId)
if (mod) total += Number(mod.additional_price)
if (mod) total += Number(mod.price)
})
return total
}, [selectedProduct, selectedModules])
@@ -41,9 +51,38 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
const prevStep = () => setStep(s => s - 1)
const toggleModule = (id: string) => {
setSelectedModules(prev =>
prev.includes(id) ? prev.filter(m => m !== id) : [...prev, id]
)
const module = selectedProduct?.modules?.find(m => m.id === id)
if (!module) return
setSelectedModules(prev => {
const isSelected = prev.includes(id)
if (isSelected) {
// When deselecting, we might need to deselect others that depend on this one
const newSelection = prev.filter(m => m !== id)
return newSelection.filter(mId => {
const m = selectedProduct?.modules?.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => newSelection.includes(reqId) || reqId === id)
}).filter(mId => mId !== id)
} else {
// When selecting, check exclusions
if (module.exclusions && module.exclusions.some(exId => prev.includes(exId))) {
return prev // Should be disabled anyway, but safety check
}
return [...prev, id]
}
})
}
const isModuleDisabled = (module: ProductModule) => {
// 1. Check requirements: all required modules must be selected
const requirementsMet = !module.requirements || module.requirements.length === 0 ||
module.requirements.every(reqId => selectedModules.includes(reqId))
// 2. Check exclusions: no excluded module must be selected
const isExcluded = module.exclusions && module.exclusions.some(exId => selectedModules.includes(exId))
return !requirementsMet || isExcluded
}
const handleSubmit = async () => {
@@ -100,33 +139,73 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={selectedProduct?.id}
onValueChange={(id) => {
const p = products.find(prod => prod.id === id)
if (p) {
setSelectedProduct(p)
setSelectedModules([]) // Reset modules when product changes
{/* Category Tabs */}
<div className="space-y-4">
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Kategorie</Label>
<Tabs value={activeCategory} onValueChange={(val) => {
setActiveCategory(val)
// Auto-select first product in new category
const firstInCat = products.find(p => val === 'all' || p.category_id === val)
if (firstInCat) {
setSelectedProduct(firstInCat)
setSelectedModules([])
}
}}
className="grid gap-4"
>
{products.map(product => (
<div key={product.id} className="relative">
<RadioGroupItem value={product.id} id={product.id} className="peer sr-only" />
<Label
htmlFor={product.id}
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<span className="font-bold text-lg">{product.name}</span>
<span className="text-sm text-muted-foreground">{product.description}</span>
<span className="mt-2 text-primary font-semibold">
Ab {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}
</span>
</Label>
</div>
))}
</RadioGroup>
}}>
<TabsList className="bg-white/5 border border-white/10 p-1 h-auto grid grid-cols-2 md:grid-cols-4 gap-2">
<TabsTrigger value="all" className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
<Package className="w-4 h-4" /> Alle
</TabsTrigger>
{categories.map(cat => {
const Icon = cat.icon === 'Cloud' ? Cloud : cat.icon === 'Utensils' ? Utensils : cat.icon === 'HardDrive' ? HardDrive : Package
return (
<TabsTrigger key={cat.id} value={cat.id} className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
<Icon className="w-4 h-4" /> {cat.name}
</TabsTrigger>
)
})}
</TabsList>
</Tabs>
</div>
<Separator className="bg-white/10" />
<div className="space-y-4">
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Lösung</Label>
<RadioGroup
value={selectedProduct?.id}
onValueChange={(id) => {
const p = products.find(prod => prod.id === id)
if (p) {
setSelectedProduct(p)
setSelectedModules([]) // Reset modules when product changes
}
}}
className="grid gap-4"
>
{filteredProducts.map(product => (
<div key={product.id} className="relative">
<RadioGroupItem value={product.id} id={product.id} className="peer sr-only" />
<Label
htmlFor={product.id}
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<div className="flex justify-between w-full items-center">
<span className="font-bold text-lg">{product.name}</span>
{product.category && (
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary">
{product.category.name}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">{product.description}</span>
<span className="mt-2 text-primary font-semibold">
Ab {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}
</span>
</Label>
</div>
))}
</RadioGroup>
</div>
{selectedProduct && selectedProduct.modules && selectedProduct.modules.length > 0 && (
<div className="space-y-4 pt-4">
@@ -141,13 +220,24 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
id={module.id}
checked={selectedModules.includes(module.id)}
onCheckedChange={() => toggleModule(module.id)}
disabled={isModuleDisabled(module)}
/>
<div className="flex-1">
<Label htmlFor={module.id} className="font-medium cursor-pointer flex justify-between">
<Label
htmlFor={module.id}
className={`font-medium cursor-pointer flex justify-between ${isModuleDisabled(module) ? 'opacity-50' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary">+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(module.additional_price)}</span>
<span className="text-primary">+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(module.price)}</span>
</Label>
<p className="text-xs text-muted-foreground">{module.description}</p>
{isModuleDisabled(module) && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements && module.requirements.some(reqId => !selectedModules.includes(reqId))
? "Benötigt weitere Module"
: "Nicht kombinierbar mit aktueller Auswahl"}
</p>
)}
</div>
</div>
))}
@@ -173,7 +263,7 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
return (
<div key={modId} className="flex justify-between text-sm">
<span className="text-muted-foreground">{mod?.name}:</span>
<span>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.additional_price || 0)}</span>
<span>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.price || 0)}</span>
</div>
)
})}

View File

@@ -6,17 +6,29 @@ export type Product = {
is_active: boolean;
created_at: string;
updated_at: string;
category_id?: string | null;
category?: Category | null;
modules?: ProductModule[];
};
export type Category = {
id: string;
name: string;
description?: string | null;
icon?: string | null;
created_at: string;
};
export type ProductModule = {
id: string;
product_id: string;
name: string;
description?: string | null | undefined;
additional_price: number;
price: number;
is_required: boolean;
is_active?: boolean;
requirements: string[];
exclusions: string[];
created_at: string;
};

View File

@@ -0,0 +1,50 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Products Table
CREATE TABLE IF NOT EXISTS public.products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
description TEXT,
base_price DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
tax_rate DECIMAL(5, 2) NOT NULL DEFAULT 19.00,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Product Modules Table
CREATE TABLE IF NOT EXISTS public.product_modules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
requirements UUID[] DEFAULT '{}',
exclusions UUID[] DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Orders Table
CREATE TABLE IF NOT EXISTS public.orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id),
customer_data JSONB NOT NULL,
order_data JSONB NOT NULL,
total_price DECIMAL(10, 2) NOT NULL,
pdf_url TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable Row Level Security
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.product_modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
-- Policies
-- Products: Read for everyone, Write for authenticated admins (simplified for now)
CREATE POLICY "Allow public read access on products" ON public.products FOR SELECT USING (true);
CREATE POLICY "Allow public read access on product_modules" ON public.product_modules FOR SELECT USING (true);
-- Orders: Users can only see their own orders
CREATE POLICY "Users can view their own orders" ON public.orders FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create their own orders" ON public.orders FOR INSERT WITH CHECK (auth.uid() = user_id);