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,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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user