845 lines
32 KiB
TypeScript
845 lines
32 KiB
TypeScript
'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'
|
|
import { SummarySidebar } from './wizard/summary-sidebar'
|
|
import { LicenseLookupPanel } from './wizard/license-lookup-panel'
|
|
|
|
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<string | null>(
|
|
initialOrder?.company_id ?? null
|
|
)
|
|
const [basketItems, setBasketItems] = useState<any[]>(() => {
|
|
if (!initialOrder || !initialOrder.order_data?.items) return []
|
|
|
|
const groups: Record<string, any[]> = {}
|
|
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<string, CategorySelection> = {}
|
|
const moduleQuantities: Record<string, number> = {}
|
|
|
|
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<string>('')
|
|
const [editingIdx, setEditingIdx] = useState<number | null>(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<Partial<Profile>>(initialProfile || {})
|
|
|
|
// Endkunden-State
|
|
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
|
|
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
|
|
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<string>(
|
|
initialOrder?.order_data?.last_license_date || ''
|
|
)
|
|
|
|
// Aktive Auswahllisten initialisieren
|
|
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
|
const initialSels: Record<string, CategorySelection> = {}
|
|
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<Record<string, number>>({})
|
|
|
|
// 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<string, CategorySelection> = {}
|
|
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<string, CategorySelection> = {}
|
|
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<string, CategorySelection> = {}
|
|
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)
|
|
|
|
// Build updated selection for this category
|
|
let updatedCatSel: CategorySelection
|
|
if (cat?.allow_multiselect) {
|
|
const currentIds = prev[catId]?.productIds || []
|
|
const nextIds = currentIds.includes(productId)
|
|
? currentIds.filter(id => id !== productId)
|
|
: [...currentIds, productId]
|
|
updatedCatSel = {
|
|
productId: nextIds[0] || null,
|
|
productIds: nextIds,
|
|
moduleIds: prev[catId]?.moduleIds || []
|
|
}
|
|
} else {
|
|
const isCurrentlyChecked = prev[catId]?.productId === productId
|
|
const nextId = isCurrentlyChecked ? null : productId
|
|
updatedCatSel = {
|
|
productId: nextId,
|
|
productIds: nextId ? [nextId] : [],
|
|
moduleIds: []
|
|
}
|
|
}
|
|
|
|
// If this category resets others, rebuild all other categories to their initial state
|
|
if (cat?.resets_others) {
|
|
const resetSels: Record<string, CategorySelection> = {}
|
|
categories.forEach(c => {
|
|
if (c.id === catId) {
|
|
resetSels[c.id] = updatedCatSel
|
|
} else {
|
|
const isVisible = selectedBillingInterval === 'one_time' ? c.show_in_kauf !== false : c.show_in_abo !== false
|
|
if (!isVisible || c.allow_multiselect || c.preselect === false) {
|
|
resetSels[c.id] = { productId: null, productIds: [], moduleIds: [] }
|
|
} else {
|
|
const first = products.find(p =>
|
|
p.category_id === c.id &&
|
|
(selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)
|
|
)
|
|
resetSels[c.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
|
|
}
|
|
}
|
|
})
|
|
// Also reset module quantities for other categories
|
|
setModuleQuantities({})
|
|
return resetSels
|
|
}
|
|
|
|
return { ...prev, [catId]: updatedCatSel }
|
|
})
|
|
}
|
|
|
|
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 (
|
|
<div className={`mx-auto px-4 ${step === 3 ? 'max-w-screen-2xl' : 'max-w-5xl'}`}>
|
|
{/* Global Stepper for Steps 1, 2, 4 */}
|
|
{step !== 3 && (
|
|
<div className="pt-12 pb-6">
|
|
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
|
|
</div>
|
|
)}
|
|
|
|
<AnimatePresence mode="wait">
|
|
{/* Step 1: Customer */}
|
|
{step === 1 && (
|
|
<motion.div
|
|
key="step1"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<StepCustomer
|
|
isAdmin={isAdmin}
|
|
companies={companies}
|
|
selectedCompanyId={selectedCompanyId}
|
|
setSelectedCompanyId={setSelectedCompanyId}
|
|
customerMode={customerMode}
|
|
setCustomerMode={setCustomerMode}
|
|
endCustomers={endCustomers}
|
|
searchTerm={searchTerm}
|
|
setSearchTerm={setSearchTerm}
|
|
filteredEndCustomers={filteredEndCustomers}
|
|
selectedEndCustomerId={selectedEndCustomerId}
|
|
setSelectedEndCustomerId={setSelectedEndCustomerId}
|
|
newCustomerForm={newCustomerForm}
|
|
setNewCustomerForm={setNewCustomerForm}
|
|
handleCreateCustomer={handleCreateCustomer}
|
|
isCreatingCustomer={isCreatingCustomer}
|
|
nextStep={nextStep}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Step 2: Billing Model */}
|
|
{step === 2 && (
|
|
<motion.div
|
|
key="step2"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<StepBilling
|
|
selectedBillingInterval={selectedBillingInterval}
|
|
handleBillingIntervalChange={handleBillingIntervalChange}
|
|
customerMode={customerMode}
|
|
selectedEndCustomerId={selectedEndCustomerId}
|
|
lastLicenseDate={lastLicenseDate}
|
|
setLastLicenseDate={setLastLicenseDate}
|
|
prevStep={prevStep}
|
|
nextStep={nextStep}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Step 3: Software Selector */}
|
|
{step === 3 && (
|
|
<motion.div
|
|
key="step3"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
>
|
|
<div className="grid grid-cols-1 lg:grid-cols-6 gap-6 items-start">
|
|
|
|
{/* FAR LEFT: License Lookup Panel (sticky) */}
|
|
<div className="lg:col-span-2 sticky top-6">
|
|
<LicenseLookupPanel />
|
|
</div>
|
|
|
|
{/* CENTER: Sticky stepper + scrollable categories */}
|
|
<div className="lg:col-span-2 space-y-0">
|
|
{/* Sticky stepper header — only covers center column */}
|
|
<div className="sticky top-0 z-20 bg-slate-950/80 backdrop-blur-md pb-4 pt-6 border-b border-white/5 mb-6">
|
|
<p className="text-center text-slate-400 text-sm mb-4">Schritt 3 von 4 — Software konfigurieren</p>
|
|
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
|
|
</div>
|
|
|
|
{/* Scrollable category content */}
|
|
<StepSoftware
|
|
visibleCategories={visibleCategories}
|
|
products={products}
|
|
selections={selections}
|
|
selectProduct={selectProduct}
|
|
isProductDisabled={isProductDisabled}
|
|
isModuleDisabled={isModuleDisabled}
|
|
toggleModule={toggleModule}
|
|
moduleQuantities={moduleQuantities}
|
|
setModuleQuantities={setModuleQuantities}
|
|
selectedBillingInterval={selectedBillingInterval}
|
|
billingLabel={billingLabel}
|
|
billingBadgeClass={billingBadgeClass}
|
|
/>
|
|
</div>
|
|
|
|
{/* RIGHT: Summary sidebar (sticky) */}
|
|
<div className="lg:col-span-2 sticky top-6">
|
|
<SummarySidebar
|
|
visibleCategories={visibleCategories}
|
|
selections={selections}
|
|
products={products}
|
|
moduleQuantities={moduleQuantities}
|
|
billingLabel={billingLabel}
|
|
oneTimeTotal={oneTimeTotal}
|
|
monthlyTotal={monthlyTotal}
|
|
oneTimeNet={oneTimeNet}
|
|
oneTimeTax={oneTimeTax}
|
|
oneTimeGross={oneTimeGross}
|
|
monthlyNet={monthlyNet}
|
|
monthlyTax={monthlyTax}
|
|
monthlyGross={monthlyGross}
|
|
updatePriceModifier={updatePriceModifier}
|
|
allCategoriesFilled={allCategoriesFilled}
|
|
productValidationErrors={productValidationErrors}
|
|
basketItems={basketItems}
|
|
editingIdx={editingIdx}
|
|
editBasketItem={editBasketItem}
|
|
deleteBasketItem={deleteBasketItem}
|
|
deviceName={deviceName}
|
|
setDeviceName={setDeviceName}
|
|
addToBasket={addToBasket}
|
|
isNextStepDisabled={isNextStepDisabled}
|
|
hasActiveSelection={hasActiveSelection}
|
|
nextStep={nextStep}
|
|
prevStep={prevStep}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Step 4: Verification & Submit */}
|
|
{step === 4 && (
|
|
<motion.div
|
|
key="step4"
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
className="space-y-6 text-center"
|
|
>
|
|
<StepSummary
|
|
finalItemsToShow={finalItemsToShow}
|
|
visibleCategories={visibleCategories}
|
|
products={products}
|
|
oneTimeTotal={oneTimeTotal}
|
|
monthlyTotal={monthlyTotal}
|
|
oneTimeNet={oneTimeNet}
|
|
oneTimeTax={oneTimeTax}
|
|
oneTimeGross={oneTimeGross}
|
|
monthlyNet={monthlyNet}
|
|
monthlyTax={monthlyTax}
|
|
monthlyGross={monthlyGross}
|
|
updatePriceModifier={updatePriceModifier}
|
|
selectedEndCustomer={selectedEndCustomer}
|
|
customerData={customerData}
|
|
lastLicenseDate={lastLicenseDate}
|
|
handleSubmit={handleSubmit}
|
|
isSubmitting={isSubmitting}
|
|
initialOrder={initialOrder}
|
|
prevStep={prevStep}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Toast Notification Container */}
|
|
<ToastNotification toast={toast} onClose={() => setToast(null)} />
|
|
</div>
|
|
)
|
|
}
|