feat: implement logic engine for module dependencies and exclusions
This commit is contained in:
@@ -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,6 +139,38 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
||||
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 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([])
|
||||
}
|
||||
}}>
|
||||
<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) => {
|
||||
@@ -111,14 +182,21 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
{products.map(product => (
|
||||
{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)}
|
||||
@@ -127,6 +205,7 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
50
shop/supabase/migrations/20240501000000_initial_schema.sql
Normal file
50
shop/supabase/migrations/20240501000000_initial_schema.sql
Normal 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);
|
||||
Reference in New Issue
Block a user