'use client' import React, { useState, useEffect, useMemo } from 'react' import { useRouter } from 'next/navigation' import { AnimatePresence, motion } from 'framer-motion' import { Product, ProductModule, Profile, EndCustomer, Order } from '@/lib/types' import { submitOrder, updateOrder } from '@/lib/actions/orders' import { createEndCustomer } from '@/lib/actions/end-customers' import { Category } from '@/lib/types' // Modular Step Components import { ProgressStepper } from './wizard/progress-stepper' import { ToastNotification } from './wizard/toast-notification' import { StepCustomer } from './wizard/step-customer' import { StepBilling } from './wizard/step-billing' import { StepSoftware } from './wizard/step-software' import { StepSummary } from './wizard/step-summary' type CategorySelection = { productId: string | null // selected product in this category productIds?: string[] // selected product IDs for multi-select moduleIds: string[] // selected module IDs for this product } function billingLabel(interval?: string) { if (interval === 'one_time') return 'einmalig' return '/ Monat' } function billingBadgeClass(interval?: string) { if (interval === 'one_time') return 'bg-slate-500/20 text-slate-300 border-slate-500/30' return 'bg-blue-500/20 text-blue-400 border-blue-500/30' } function toggleModuleInList( modules: ProductModule[], currentIds: string[], toggleId: string ): string[] { const isSelected = currentIds.includes(toggleId) if (isSelected) { let next = currentIds.filter(id => id !== toggleId) let changed = true while (changed) { const beforeLength = next.length next = next.filter(mId => { const m = modules.find(mod => mod.id === mId) return !m?.requirements || m.requirements.length === 0 || m.requirements.some(reqId => next.includes(reqId)) }) changed = next.length !== beforeLength } return next } else { const module = modules.find(m => m.id === toggleId) if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds return [...currentIds, toggleId] } } function isModuleDisabled(module: ProductModule, selectedModules: string[]): boolean { const requirementsMet = !module.requirements?.length || module.requirements.some(reqId => selectedModules.includes(reqId)) const isExcluded = module.exclusions?.some(exId => selectedModules.includes(exId)) return !requirementsMet || !!isExcluded } export function OrderWizard({ products, categories, initialProfile, initialEndCustomers, initialOrder, isAdmin = false, companies = [], }: { products: Product[] categories: Category[] initialProfile: Profile | null initialEndCustomers: EndCustomer[] initialOrder?: Order | null isAdmin?: boolean companies?: any[] }) { const router = useRouter() const [step, setStep] = useState(initialOrder ? 3 : 1) const [isSubmitting, setIsSubmitting] = useState(false) const [selectedCompanyId, setSelectedCompanyId] = useState( initialOrder?.company_id ?? null ) const [basketItems, setBasketItems] = useState(() => { if (!initialOrder || !initialOrder.order_data?.items) return [] const groups: Record = {} initialOrder.order_data.items.forEach((item: any) => { const devName = item.device_name || 'Kasse 1' if (!groups[devName]) { groups[devName] = [] } groups[devName].push(item) }) return Object.entries(groups).map(([deviceName, devItems]) => { const selections: Record = {} const moduleQuantities: Record = {} categories.forEach(cat => { selections[cat.id] = { productId: null, productIds: [], moduleIds: [] } }) devItems.forEach((item: any) => { const catId = item.category_id if (!selections[catId]) { selections[catId] = { productId: null, productIds: [], moduleIds: [] } } const cat = categories.find(c => c.id === catId) if (cat?.allow_multiselect) { selections[catId].productIds = [...(selections[catId].productIds || []), item.product_id] selections[catId].productId = selections[catId].productIds[0] || null } else { selections[catId].productId = item.product_id selections[catId].productIds = [item.product_id] } item.selected_modules?.forEach((mod: any) => { selections[catId].moduleIds.push(mod.module_id) moduleQuantities[mod.module_id] = mod.quantity || 1 }) }) return { deviceName, selections, moduleQuantities, billingInterval: devItems[0]?.billing_interval || 'monthly' } }) }) const [deviceName, setDeviceName] = useState('') const [editingIdx, setEditingIdx] = useState(null) const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' } | null>(null) useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 5000) return () => clearTimeout(timer) } }, [toast]) // Partner-Profil (Fallback) const [customerData] = useState>(initialProfile || {}) // Endkunden-State const [endCustomers, setEndCustomers] = useState(initialEndCustomers) const [selectedEndCustomerId, setSelectedEndCustomerId] = useState( initialOrder ? initialOrder.end_customer_id : (initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null) ) const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null const [searchTerm, setSearchTerm] = useState('') const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select') // Formular-State für neuen Endkunden const [newCustomerForm, setNewCustomerForm] = useState({ company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '', }) const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) // Standard-Abrechnungsmodell: falls initialOrder vorhanden, dessen Intervall nehmen const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>(() => { if (initialOrder?.order_data?.items?.[0]?.billing_interval) { return initialOrder.order_data.items[0].billing_interval as 'one_time' | 'monthly' } return 'one_time' }) // Falls "one_time" (Kauf) gewählt: optionales Datum der letzten CAS-Lizenzierung const [lastLicenseDate, setLastLicenseDate] = useState( initialOrder?.order_data?.last_license_date || '' ) // Aktive Auswahllisten initialisieren const [selections, setSelections] = useState>(() => { const initialSels: Record = {} categories.forEach(cat => { const isVisible = selectedBillingInterval === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false if (!isVisible || cat.allow_multiselect || cat.preselect === false) { initialSels[cat.id] = { productId: null, productIds: [], moduleIds: [] } } else { const first = products.find(p => p.category_id === cat.id && (selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)) initialSels[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] } } }) return initialSels }) const [moduleQuantities, setModuleQuantities] = useState>({}) // Filtere sichtbare Kategorien nach Abrechnungsmodell const visibleCategories = useMemo(() => { return categories.filter(cat => { return selectedBillingInterval === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false }) }, [categories, selectedBillingInterval]) // Endkunden Filterung const filteredEndCustomers = useMemo(() => { const term = searchTerm.toLowerCase().trim() return endCustomers .filter(c => !c.is_anonymized) .filter(c => { if (!term) return true return ( c.company_name?.toLowerCase().includes(term) || c.city?.toLowerCase().includes(term) || c.zip?.toLowerCase().includes(term) || `${c.first_name} ${c.last_name}`.toLowerCase().includes(term) ) }) }, [endCustomers, searchTerm]) const hasActiveSelection = useMemo(() => { return Object.values(selections).some(sel => sel.productId || (sel.productIds && sel.productIds.length > 0)) }, [selections]) // Product validation errors (requirements and exclusions) const productValidationErrors = useMemo(() => { const errors: string[] = [] // Check for duplicate device names const currentName = deviceName.trim() || (editingIdx !== null ? basketItems[editingIdx]?.deviceName : `Kasse ${basketItems.length + 1}`) const isDuplicate = basketItems.some((item, idx) => { if (editingIdx !== null && idx === editingIdx) return false return item.deviceName.toLowerCase().trim() === currentName.toLowerCase().trim() }) if (isDuplicate) { errors.push(`Gerätename "${currentName}" existiert bereits im Warenkorb. Bitte einen anderen Namen wählen.`) } const selectedProductIds: string[] = [] Object.values(selections).forEach(s => { if (s.productIds && s.productIds.length > 0) { s.productIds.forEach(id => { if (id) selectedProductIds.push(id) }) } else if (s.productId) { selectedProductIds.push(s.productId) } }) selectedProductIds.forEach(prodId => { const prod = products.find(p => p.id === prodId) if (!prod) return // Requirements check if (prod.requirements && prod.requirements.length > 0) { const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId)) if (!hasMetRequirement) { const names = prod.requirements .map(reqId => products.find(p => p.id === reqId)?.name || reqId) .join(' oder ') errors.push(`"${prod.name}" benötigt mindestens eines dieser Produkte: ${names}`) } } // Exclusions check if (prod.exclusions && prod.exclusions.length > 0) { const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId)) if (conflicting.length > 0) { const names = conflicting .map(exId => products.find(p => p.id === exId)?.name || exId) .join(', ') errors.push(`"${prod.name}" schließt aus: ${names}`) } } }) return errors }, [selections, products, deviceName, basketItems, editingIdx]) // Total price calculations (split by billing interval) const { monthlyTotal, oneTimeTotal } = useMemo(() => { let monthly = 0 let oneTime = 0 for (const cat of categories) { const sel = selections[cat.id] if (!sel) continue const selectedProds: Product[] = [] if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) { sel.productIds.forEach(pId => { const p = products.find(prod => prod.id === pId) if (p) selectedProds.push(p) }) } else if (sel.productId) { const p = products.find(prod => prod.id === sel.productId) if (p) selectedProds.push(p) } const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0 const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price) sortedProds.forEach((prod, idx) => { const isFree = idx < freeLimit const basePrice = isFree ? 0 : prod.base_price if (prod.billing_interval === 'monthly') { monthly += basePrice } else { oneTime += basePrice } // Add module prices sel.moduleIds.forEach(mId => { const mod = prod.modules?.find(m => m.id === mId) if (mod) { const qty = moduleQuantities[mId] || 1 if (prod.billing_interval === 'monthly') { monthly += mod.price * qty } else { oneTime += mod.price * qty } } }) }) } return { monthlyTotal: monthly, oneTimeTotal: oneTime } }, [selections, products, categories, moduleQuantities]) // Ermittlung der Update-Faktoren basierend auf dem Lizenzdatum (nur für Kauf/einmalig relevant) const updatePriceModifier = useMemo(() => { if (selectedBillingInterval !== 'one_time' || !lastLicenseDate) return { factor: 1 } const lastDate = new Date(lastLicenseDate) const now = new Date() const diffTime = Math.abs(now.getTime() - lastDate.getTime()) const diffMonths = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 30)) if (diffMonths <= 12) { return { factor: 0.15, label: 'Update innerhalb 1 Jahr (15% des Neupreises)', diffMonths } } else if (diffMonths <= 24) { return { factor: 0.30, label: 'Update innerhalb 2 Jahre (30% des Neupreises)', diffMonths } } else if (diffMonths <= 36) { return { factor: 0.50, label: 'Update innerhalb 3 Jahre (50% des Neupreises)', diffMonths } } else { return { factor: 1.0, label: 'Älter als 3 Jahre: Voller Neupreis', diffMonths } } }, [selectedBillingInterval, lastLicenseDate]) // Endbeträge ermitteln (Netto / Steuer / Brutto) const discountFactor = updatePriceModifier.factor ?? 1 const oneTimeNet = oneTimeTotal * discountFactor const oneTimeTax = oneTimeNet * 0.19 const oneTimeGross = oneTimeNet + oneTimeTax const monthlyNet = monthlyTotal const monthlyTax = monthlyNet * 0.19 const monthlyGross = monthlyNet + monthlyTax // Only REQUIRED categories must have a product selected const allCategoriesFilled = useMemo(() => { return visibleCategories .filter(cat => cat.is_required) .every(cat => { const sel = selections[cat.id] if (cat.allow_multiselect) { if (!sel?.productIds || sel.productIds.length === 0) return false return sel.productIds.every(pId => { const prod = products.find(p => p.id === pId) if (!prod) return false return selectedBillingInterval === 'one_time' ? prod.show_in_kauf !== false : prod.show_in_abo !== false }) } if (!sel?.productId) return false const prod = products.find(p => p.id === sel.productId) if (!prod) return false return selectedBillingInterval === 'one_time' ? prod.show_in_kauf !== false : prod.show_in_abo !== false }) }, [visibleCategories, selections, products, selectedBillingInterval]) const isNextStepDisabled = !allCategoriesFilled || productValidationErrors.length > 0 // Helper: toggle module for a category's selected product function toggleModule(catId: string, moduleId: string) { setSelections(prev => { const sel = prev[catId] const activeProductId = sel?.productId if (!activeProductId) return prev const prod = products.find(p => p.id === activeProductId) const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId) return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } } }) setModuleQuantities(prev => { if (prev[moduleId] !== undefined) { const { [moduleId]: _, ...rest } = prev return rest } return { ...prev, [moduleId]: 1 } }) } const nextStep = () => { if (step === 3 && allCategoriesFilled && productValidationErrors.length === 0) { if (hasActiveSelection) { addToBasket() } } setStep(s => s + 1) } const prevStep = () => { setStep(s => s - 1) } function handleBillingIntervalChange(val: 'one_time' | 'monthly') { setSelectedBillingInterval(val) if (val === 'monthly') { setLastLicenseDate('') } const newSelections: Record = {} categories.forEach(cat => { const isVisible = val === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false if (!isVisible || cat.allow_multiselect || cat.preselect === false) { newSelections[cat.id] = { productId: null, productIds: [], moduleIds: [] } } else { const matchingProd = products.find(p => p.category_id === cat.id && (val === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false) ) newSelections[cat.id] = { productId: matchingProd?.id ?? null, productIds: matchingProd ? [matchingProd.id] : [], moduleIds: [] } } }) setSelections(newSelections) } // Neuen Endkunden inline anlegen const handleCreateCustomer = async () => { if (!newCustomerForm.company_name.trim()) return setIsCreatingCustomer(true) try { const created = await createEndCustomer(newCustomerForm, selectedCompanyId || undefined) setEndCustomers(prev => [...prev, created]) setSelectedEndCustomerId(created.id) setCustomerMode('select') setNewCustomerForm({ company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '', }) } catch (e: any) { setToast({ message: `Fehler beim Anlegen des Kunden: ${e.message || 'Unbekannter Fehler'}`, type: 'error' }) } finally { setIsCreatingCustomer(false) } } const addToBasket = () => { const currentItem = { deviceName: deviceName || (editingIdx !== null ? basketItems[editingIdx]?.deviceName : `Kasse ${basketItems.length + 1}`), selections: JSON.parse(JSON.stringify(selections)), moduleQuantities: { ...moduleQuantities }, billingInterval: selectedBillingInterval, } if (editingIdx !== null) { setBasketItems(prev => { const next = [...prev] next[editingIdx] = currentItem return next }) setEditingIdx(null) } else { setBasketItems(prev => [...prev, currentItem]) } // Reset current form config setDeviceName('') setModuleQuantities({}) const resetSels: Record = {} categories.forEach(cat => { const isVisible = selectedBillingInterval === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false if (!isVisible || cat.allow_multiselect || cat.preselect === false) { resetSels[cat.id] = { productId: null, productIds: [], moduleIds: [] } } else { const first = products.find(p => p.category_id === cat.id && (selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)) resetSels[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] } } }) setSelections(resetSels) } const editBasketItem = (idx: number) => { const item = basketItems[idx] if (!item) return setEditingIdx(idx) setSelections(item.selections) setModuleQuantities(item.moduleQuantities) setDeviceName(item.deviceName) setSelectedBillingInterval(item.billingInterval) } const deleteBasketItem = (idx: number) => { if (editingIdx === idx) { setEditingIdx(null) setModuleQuantities({}) setDeviceName('') const resetSels: Record = {} categories.forEach(cat => { const isVisible = selectedBillingInterval === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false if (!isVisible || cat.allow_multiselect || cat.preselect === false) { resetSels[cat.id] = { productId: null, productIds: [], moduleIds: [] } } else { const first = products.find(p => p.category_id === cat.id && (selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)) resetSels[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] } } }) setSelections(resetSels) } else if (editingIdx !== null && idx < editingIdx) { setEditingIdx(editingIdx - 1) } setBasketItems(prev => prev.filter((_, i) => i !== idx)) } const handleSubmit = async () => { if (isSubmitting) return setIsSubmitting(true) try { let finalItems = [...basketItems] if (hasActiveSelection && allCategoriesFilled && productValidationErrors.length === 0) { const currentItem = { deviceName: deviceName || (editingIdx !== null ? basketItems[editingIdx]?.deviceName : `Kasse ${basketItems.length + 1}`), selections: JSON.parse(JSON.stringify(selections)), moduleQuantities: { ...moduleQuantities }, billingInterval: selectedBillingInterval, } if (editingIdx !== null) { finalItems[editingIdx] = currentItem } else { finalItems.push(currentItem) } } if (finalItems.length === 0) { throw new Error('Bitte konfigurieren Sie mindestens eine Kasse.') } const res = await fetch('/api/orders/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ items: finalItems, customerProfile: customerData, endCustomerId: selectedEndCustomerId, endCustomer: selectedEndCustomer, }) }) const result = await res.json() if (result.error) throw new Error(result.error) const firstOrderId = result.orders?.[0]?.id if (firstOrderId) { router.push(`/order/success?id=${firstOrderId}`) } else { router.push('/my-orders') } } catch (error: any) { console.error(error) setToast({ message: error?.message || 'Fehler bei der Bestellung. Bitte versuchen Sie es erneut.', type: 'error' }) setIsSubmitting(false) } } const finalItemsToShow = basketItems.length > 0 ? basketItems : (allCategoriesFilled && productValidationErrors.length === 0 ? [{ deviceName: deviceName || 'Kasse 1', selections, moduleQuantities, billingInterval: selectedBillingInterval }] : []) function selectProduct(catId: string, productId: string) { setSelections(prev => { const cat = categories.find(c => c.id === catId) if (cat?.allow_multiselect) { const currentIds = prev[catId]?.productIds || [] const nextIds = currentIds.includes(productId) ? currentIds.filter(id => id !== productId) : [...currentIds, productId] return { ...prev, [catId]: { productId: nextIds[0] || null, productIds: nextIds, moduleIds: prev[catId]?.moduleIds || [] } } } else { const isCurrentlyChecked = prev[catId]?.productId === productId const nextId = isCurrentlyChecked ? null : productId return { ...prev, [catId]: { productId: nextId, productIds: nextId ? [nextId] : [], moduleIds: [] } } } }) } function isProductDisabled(product: Product, catId: string): boolean { const otherSelectedProductIds: string[] = [] Object.entries(selections).forEach(([cId, sel]) => { if (cId === catId) return if (sel.productIds && sel.productIds.length > 0) { otherSelectedProductIds.push(...sel.productIds) } else if (sel.productId) { otherSelectedProductIds.push(sel.productId) } }) const hasExclusions = product.exclusions?.some(exId => otherSelectedProductIds.includes(exId)) const meetsRequirements = !product.requirements?.length || product.requirements.some(reqId => otherSelectedProductIds.includes(reqId)) return !!hasExclusions || !meetsRequirements } return (
{/* Progress Stepper */} {step !== 3 && ( )} {/* Step 1: Customer */} {step === 1 && ( )} {/* Step 2: Billing Model */} {step === 2 && ( )} {/* Step 3: Software Selector */} {step === 3 && ( )} {/* Step 4: Verification & Submit */} {step === 4 && ( )} {/* Toast Notification Container */} setToast(null)} />
) }