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 { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
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 { 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 [step, setStep] = useState(1)
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(products[0] || null)
|
const [activeCategory, setActiveCategory] = useState<string>(categories[0]?.id || 'all')
|
||||||
const [selectedModules, setSelectedModules] = useState<string[]>([])
|
|
||||||
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
|
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
|
||||||
company_name: '',
|
company_name: '',
|
||||||
vat_id: '',
|
vat_id: '',
|
||||||
@@ -27,12 +29,20 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
|||||||
zip_code: ''
|
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(() => {
|
const totalPrice = useMemo(() => {
|
||||||
if (!selectedProduct) return 0
|
if (!selectedProduct) return 0
|
||||||
let total = Number(selectedProduct.base_price)
|
let total = Number(selectedProduct.base_price)
|
||||||
selectedModules.forEach(modId => {
|
selectedModules.forEach(modId => {
|
||||||
const mod = selectedProduct.modules?.find(m => m.id === 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
|
return total
|
||||||
}, [selectedProduct, selectedModules])
|
}, [selectedProduct, selectedModules])
|
||||||
@@ -41,9 +51,38 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
|||||||
const prevStep = () => setStep(s => s - 1)
|
const prevStep = () => setStep(s => s - 1)
|
||||||
|
|
||||||
const toggleModule = (id: string) => {
|
const toggleModule = (id: string) => {
|
||||||
setSelectedModules(prev =>
|
const module = selectedProduct?.modules?.find(m => m.id === id)
|
||||||
prev.includes(id) ? prev.filter(m => m !== id) : [...prev, 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 () => {
|
const handleSubmit = async () => {
|
||||||
@@ -100,33 +139,73 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
|||||||
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
|
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<RadioGroup
|
{/* Category Tabs */}
|
||||||
value={selectedProduct?.id}
|
<div className="space-y-4">
|
||||||
onValueChange={(id) => {
|
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Kategorie</Label>
|
||||||
const p = products.find(prod => prod.id === id)
|
<Tabs value={activeCategory} onValueChange={(val) => {
|
||||||
if (p) {
|
setActiveCategory(val)
|
||||||
setSelectedProduct(p)
|
// Auto-select first product in new category
|
||||||
setSelectedModules([]) // Reset modules when product changes
|
const firstInCat = products.find(p => val === 'all' || p.category_id === val)
|
||||||
|
if (firstInCat) {
|
||||||
|
setSelectedProduct(firstInCat)
|
||||||
|
setSelectedModules([])
|
||||||
}
|
}
|
||||||
}}
|
}}>
|
||||||
className="grid gap-4"
|
<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">
|
||||||
{products.map(product => (
|
<Package className="w-4 h-4" /> Alle
|
||||||
<div key={product.id} className="relative">
|
</TabsTrigger>
|
||||||
<RadioGroupItem value={product.id} id={product.id} className="peer sr-only" />
|
{categories.map(cat => {
|
||||||
<Label
|
const Icon = cat.icon === 'Cloud' ? Cloud : cat.icon === 'Utensils' ? Utensils : cat.icon === 'HardDrive' ? HardDrive : Package
|
||||||
htmlFor={product.id}
|
return (
|
||||||
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"
|
<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}
|
||||||
<span className="font-bold text-lg">{product.name}</span>
|
</TabsTrigger>
|
||||||
<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)}
|
</TabsList>
|
||||||
</span>
|
</Tabs>
|
||||||
</Label>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
<Separator className="bg-white/10" />
|
||||||
</RadioGroup>
|
|
||||||
|
<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 && (
|
{selectedProduct && selectedProduct.modules && selectedProduct.modules.length > 0 && (
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
@@ -141,13 +220,24 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
|||||||
id={module.id}
|
id={module.id}
|
||||||
checked={selectedModules.includes(module.id)}
|
checked={selectedModules.includes(module.id)}
|
||||||
onCheckedChange={() => toggleModule(module.id)}
|
onCheckedChange={() => toggleModule(module.id)}
|
||||||
|
disabled={isModuleDisabled(module)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<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>{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>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">{module.description}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -173,7 +263,7 @@ export function OrderWizard({ products, initialProfile }: { products: Product[],
|
|||||||
return (
|
return (
|
||||||
<div key={modId} className="flex justify-between text-sm">
|
<div key={modId} className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{mod?.name}:</span>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,17 +6,29 @@ export type Product = {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
category_id?: string | null;
|
||||||
|
category?: Category | null;
|
||||||
modules?: ProductModule[];
|
modules?: ProductModule[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductModule = {
|
export type ProductModule = {
|
||||||
id: string;
|
id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null | undefined;
|
description?: string | null | undefined;
|
||||||
additional_price: number;
|
price: number;
|
||||||
is_required: boolean;
|
is_required: boolean;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
requirements: string[];
|
||||||
|
exclusions: string[];
|
||||||
created_at: 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