Files
webshop/shop/components/order-wizard.tsx
DanielS 2750898524
All checks were successful
Staging Build / build (push) Successful in 3m0s
fix(wizard): remove invalid orientation prop from ProgressStepper
2026-08-11 22:20:02 +02:00

1111 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { isLicenseNumberTaken } from '@/lib/actions/queries'
import { Category } from '@/lib/types'
import { Check } from 'lucide-react'
// 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, CategoryIcon } 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 = [],
upgradeMode = false,
initialEndCustomerId = null,
lockedDeviceId = null,
}: {
products: Product[]
categories: Category[]
initialProfile: Profile | null
initialEndCustomers: EndCustomer[]
initialOrder?: Order | null
isAdmin?: boolean
companies?: any[]
upgradeMode?: boolean
initialEndCustomerId?: string | null
lockedDeviceId?: string | null
}) {
const router = useRouter()
// Upgrade-Mode: starte direkt bei Schritt 3 (Software)
const [step, setStep] = useState(upgradeMode ? 3 : (initialOrder ? 3 : 1))
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(
initialOrder?.company_id ?? (isAdmin ? 'all' : 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'
}
})
})
// Upgrade-Mode: Ermittle bereits lizenzierte Modul-IDs für die Ziel-Kasse
const existingModuleIds = useMemo<string[]>(() => {
if (!upgradeMode || !initialOrder || !lockedDeviceId) return []
const items = initialOrder.order_data?.items || []
const deviceItems = items.filter(
(item: any) => (item.device_name || 'Kasse 1') === lockedDeviceId
)
const moduleIds: string[] = []
deviceItems.forEach((item: any) => {
item.selected_modules?.forEach((mod: any) => {
if (mod.module_id) moduleIds.push(mod.module_id)
})
})
return moduleIds
}, [upgradeMode, initialOrder, lockedDeviceId])
const [deviceName, setDeviceName] = useState<string>(() => {
if (upgradeMode && lockedDeviceId) return `${lockedDeviceId} Upgrade`
return ''
})
const [licenseNumber, setLicenseNumber] = useState<string>('')
const [editingIdx, setEditingIdx] = useState<number | null>(null)
const [orderNotes, setOrderNotes] = useState<string>('')
const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' } | null>(null)
const [activeCategoryId, setActiveCategoryId] = useState<string | 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>(() => {
// Upgrade-Mode: Kundenauswahl aus URL-Param
if (upgradeMode && initialEndCustomerId) return initialEndCustomerId
if (initialOrder) return initialOrder.end_customer_id
return 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 CASPOS-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])
useEffect(() => {
if (visibleCategories.length > 0 && !visibleCategories.some(c => c.id === activeCategoryId)) {
setActiveCategoryId(visibleCategories[0].id)
}
}, [visibleCategories, activeCategoryId])
// 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 finalItemsToShow = basketItems.length > 0
? basketItems
: (allCategoriesFilled && productValidationErrors.length === 0
? [{ deviceName: deviceName || 'Kasse 1', selections, moduleQuantities, billingInterval: selectedBillingInterval }]
: [])
// Gesamtsummen für alle Items in der Bestellung (finalItemsToShow)
const { overallMonthlyTotal, overallOneTimeTotal, linkedFeeProducts } = useMemo(() => {
let monthly = 0
let oneTime = 0
const feeProductIds = new Set<string>()
finalItemsToShow.forEach(item => {
categories.forEach(cat => {
const sel = item.selections[cat.id]
if (!sel) return
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) {
sel.productIds.forEach((pId: string) => {
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
}
if (prod.linked_fee_product_id) {
feeProductIds.add(prod.linked_fee_product_id)
}
// Module
sel.moduleIds?.forEach((mId: string) => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) {
const qty = item.moduleQuantities?.[mId] || 1
if (prod.billing_interval === 'monthly') {
monthly += mod.price * qty
} else {
oneTime += mod.price * qty
}
}
})
})
})
})
const feeProducts: Product[] = []
feeProductIds.forEach(id => {
const p = products.find(prod => prod.id === id)
if (p) {
feeProducts.push(p)
if (p.billing_interval === 'monthly') {
monthly += p.base_price
} else {
oneTime += p.base_price
}
}
})
return {
overallMonthlyTotal: monthly,
overallOneTimeTotal: oneTime,
linkedFeeProducts: feeProducts
}
}, [finalItemsToShow, products, categories])
// Gesamte Endbeträge ermitteln
const overallOneTimeNet = overallOneTimeTotal * discountFactor
const overallOneTimeTax = overallOneTimeNet * 0.19
const overallOneTimeGross = overallOneTimeNet + overallOneTimeTax
const overallMonthlyNet = overallMonthlyTotal
const overallMonthlyTax = overallMonthlyNet * 0.19
const overallMonthlyGross = overallMonthlyNet + overallMonthlyTax
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 = async () => {
const normLicense = licenseNumber.trim().toUpperCase()
if (normLicense) {
const isDuplicateInBasket = basketItems.some(
(item, idx) => idx !== editingIdx && item.licenseNumber?.toUpperCase().trim() === normLicense
)
if (isDuplicateInBasket) {
setToast({ message: `Lizenznummer "${licenseNumber}" ist bereits im aktuellen Warenkorb vorhanden.`, type: 'error' })
return
}
const isTaken = await isLicenseNumberTaken(normLicense, initialOrder?.id)
if (isTaken) {
setToast({ message: `Lizenznummer "${licenseNumber}" wird bereits von einer anderen Bestellung verwendet.`, type: 'error' })
return
}
}
const currentItem = {
deviceName: deviceName || (editingIdx !== null ? basketItems[editingIdx]?.deviceName : `Kasse ${basketItems.length + 1}`),
licenseNumber: normLicense,
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('')
setLicenseNumber('')
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)
setLicenseNumber(item.licenseNumber || '')
setSelectedBillingInterval(item.billingInterval)
}
const deleteBasketItem = (idx: number) => {
if (editingIdx === idx) {
setEditingIdx(null)
setModuleQuantities({})
setDeviceName('')
setLicenseNumber('')
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 normLicense = licenseNumber.trim().toUpperCase()
if (normLicense) {
const isDuplicateInBasket = basketItems.some(
(item, idx) => idx !== editingIdx && item.licenseNumber?.toUpperCase().trim() === normLicense
)
if (isDuplicateInBasket) {
throw new Error(`Lizenznummer "${licenseNumber}" ist bereits im aktuellen Warenkorb vorhanden.`)
}
const isTaken = await isLicenseNumberTaken(normLicense, initialOrder?.id)
if (isTaken) {
throw new Error(`Lizenznummer "${licenseNumber}" wird bereits von einer anderen Bestellung verwendet.`)
}
}
const currentItem = {
deviceName: deviceName || (editingIdx !== null ? basketItems[editingIdx]?.deviceName : `Kasse ${basketItems.length + 1}`),
licenseNumber: normLicense,
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.')
}
// Check all license numbers in finalItems for duplicates
const licenseNumbers = finalItems.map(item => item.licenseNumber?.toUpperCase().trim()).filter(Boolean)
const uniqueLicenses = new Set(licenseNumbers)
if (uniqueLicenses.size !== licenseNumbers.length) {
throw new Error('Eine Lizenznummer darf im Warenkorb nicht mehrfach verwendet werden.')
}
for (const lic of uniqueLicenses) {
const isTaken = await isLicenseNumberTaken(lic, initialOrder?.id)
if (isTaken) {
throw new Error(`Die Lizenznummer "${lic}" wird bereits von einer anderen Bestellung verwendet.`)
}
}
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,
lastLicenseDate: lastLicenseDate || null,
notes: orderNotes || null,
})
})
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)
}
}
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="max-w-7xl mx-auto px-4 py-6">
{/* Global Main Header over all columns */}
<div className="mb-6">
<h1 className="text-3xl font-extrabold tracking-tight mb-1 text-gradient">
Konfigurieren Sie Ihre Lösung
</h1>
<p className="text-slate-400 text-sm">
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left Side Status Stepper & Categories for ALL steps */}
<div className="lg:col-span-3 bg-slate-900/50 backdrop-blur-md border border-white/10 rounded-2xl p-6 space-y-6 sticky top-6">
<div>
<h2 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-1">
Fortschritt
</h2>
<p className="text-lg font-extrabold text-white">
Bestell-Wizard
</p>
</div>
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
{/* Step 3 Categories inside left sidebar */}
{step === 3 && (
<div className="pt-4 border-t border-white/10 space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Kategorien
</h3>
<div className="flex flex-col gap-2">
{visibleCategories.map((cat) => {
const sel = selections[cat.id]
const hasSelection = (sel?.productIds && sel.productIds.length > 0) || !!sel?.productId
const isRequired = cat.is_required
const isActive = activeCategoryId === cat.id
return (
<button
key={cat.id}
onClick={() => setActiveCategoryId(cat.id)}
className={`flex items-center gap-2.5 p-2.5 rounded-xl border transition-all duration-300 text-left relative overflow-hidden group ${isActive
? 'bg-primary/10 border-primary text-white shadow-[0_0_15px_rgba(59,130,246,0.2)]'
: 'bg-slate-900/50 border-white/5 text-slate-400 hover:bg-slate-900 hover:border-white/20 hover:text-white'
}`}
>
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
)}
<div className={`p-1.5 rounded-lg transition-colors ${isActive ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400 group-hover:text-white'}`}>
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-xs font-semibold truncate ${isActive ? 'text-white' : 'text-slate-300 group-hover:text-white'}`}>
{cat.name}
</p>
</div>
{hasSelection ? (
<span className="text-[9px] px-1.5 py-0.2 rounded-full font-semibold shrink-0 border border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
</span>
) : isRequired ? (
<span className="text-[9px] px-1.5 py-0.2 rounded-full font-semibold shrink-0 border border-amber-500/30 text-amber-400 bg-amber-500/10">
!
</span>
) : null}
</button>
)
})}
</div>
</div>
)}
</div>
{/* Right Main Content Column */}
<div className="lg:col-span-9 space-y-6">
<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 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="h-[calc(100vh-160px)] overflow-hidden bg-slate-950/70 backdrop-blur-xl border border-slate-800/80 rounded-2xl shadow-2xl p-2"
>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 h-full items-stretch">
{/* LEFT: Scrollable Category Selection */}
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden p-2 px-3">
<div className="flex-1 overflow-y-auto pr-2 subpixel-antialiased scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{/* Upgrade-Modus Hinweis-Banner */}
{upgradeMode && lockedDeviceId && (
<div className="mb-4 p-3 rounded-xl bg-primary/10 border border-primary/20 text-xs text-primary/90 flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
<div>
<p className="font-semibold">Upgrade-Modus: {lockedDeviceId}</p>
<p className="text-primary/70 mt-0.5">Bereits lizenzierte Module sind ausgegraut und können nicht doppelt gebucht werden.</p>
</div>
</div>
)}
<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}
existingModuleIds={existingModuleIds}
activeCategoryId={activeCategoryId}
/>
</div>
</div>
{/* RIGHT: Summary sidebar */}
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden p-2 pl-2">
<div className="flex-1 overflow-hidden">
<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}
licenseNumber={licenseNumber}
setLicenseNumber={setLicenseNumber}
addToBasket={addToBasket}
isNextStepDisabled={isNextStepDisabled}
hasActiveSelection={hasActiveSelection}
nextStep={nextStep}
prevStep={prevStep}
/>
</div>
</div>
</div>
</motion.div>
)}
{/* Step 4: Verification & Submit */}
{step === 4 && (
<motion.div
key="step4"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
className="h-[calc(100vh-140px)] overflow-hidden w-full"
>
<StepSummary
finalItemsToShow={finalItemsToShow}
visibleCategories={visibleCategories}
products={products}
oneTimeTotal={overallOneTimeTotal}
monthlyTotal={overallMonthlyTotal}
oneTimeNet={overallOneTimeNet}
oneTimeTax={overallOneTimeTax}
oneTimeGross={overallOneTimeGross}
monthlyNet={overallMonthlyNet}
monthlyTax={overallMonthlyTax}
monthlyGross={overallMonthlyGross}
updatePriceModifier={updatePriceModifier}
selectedEndCustomer={selectedEndCustomer}
customerData={customerData}
lastLicenseDate={lastLicenseDate}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
initialOrder={initialOrder}
prevStep={prevStep}
linkedFeeProducts={linkedFeeProducts}
orderNotes={orderNotes}
setOrderNotes={setOrderNotes}
/>
</motion.div>
)}
</AnimatePresence>
{/* Toast Notification Container */}
<ToastNotification toast={toast} onClose={() => setToast(null)} />
</div>
</div>
</div>
)
}