Files
webshop/shop/components/order-wizard.tsx

1151 lines
45 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={`mx-auto px-4 ${step === 3 ? 'max-w-screen-2xl' : 'max-w-5xl'}`}>
{/* Dynamic Header */}
{step !== 3 && (
<div className="text-center mb-12">
<h1 className="text-5xl font-extrabold tracking-tight mb-4 text-gradient">
Konfigurieren Sie Ihre Lösung
</h1>
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
</p>
</div>
)}
{/* 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 }}
className="h-[calc(100vh-140px)] overflow-hidden"
>
<div className="grid grid-cols-1 lg:grid-cols-6 gap-6 h-full items-stretch">
{/* LEFT: Categories Sidebar Menu */}
<div className="lg:col-span-2 flex flex-col justify-start pr-4 space-y-6">
<div>
<h1 className="text-3xl font-extrabold tracking-tight mb-2 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>
{/* Left Sidebar Status Stepper */}
<div className="space-y-4 py-4 border-t border-b border-white/5">
<p className="text-xs font-bold uppercase tracking-wider text-slate-500">
Schritt 3 von 4 Software konfigurieren
</p>
<div className="flex flex-col gap-3 pl-1">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/20 border border-green-500/35 flex items-center justify-center text-green-400 text-xs">
<Check className="w-3.5 h-3.5" />
</div>
<span className="text-sm text-slate-400 font-medium">Kunde</span>
</div>
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/20 border border-green-500/35 flex items-center justify-center text-green-400 text-xs">
<Check className="w-3.5 h-3.5" />
</div>
<span className="text-sm text-slate-400 font-medium">Modell</span>
</div>
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full bg-primary/20 border border-primary text-primary flex items-center justify-center text-xs font-bold shadow-[0_0_10px_rgba(59,130,246,0.2)]">
3
</div>
<span className="text-sm text-white font-bold">Software</span>
</div>
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full bg-white/5 border border-white/10 text-slate-500 flex items-center justify-center text-xs">
4
</div>
<span className="text-sm text-slate-600 font-medium">Abschluss</span>
</div>
</div>
</div>
<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-3 p-3.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_20px_rgba(59,130,246,0.15)]'
: 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10'
}`}
>
{/* Left glow line for active category */}
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary" />
)}
<div className={`p-2 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-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold truncate ${isActive ? 'text-white' : 'text-slate-300 group-hover:text-white'}`}>
{cat.name}
</p>
<p className="text-[11px] text-slate-500 truncate mt-0.5">
{cat.description || 'Optionen ansehen'}
</p>
</div>
{/* Status indicators */}
{hasSelection ? (
<span className="text-[10px] px-2 py-0.5 rounded-full font-medium shrink-0 border border-green-500/30 text-green-400 bg-green-500/10">
Ausgewählt
</span>
) : isRequired ? (
<span className="text-[10px] px-2 py-0.5 rounded-full font-medium shrink-0 border border-red-500/30 text-red-400 bg-red-500/10">
Erforderlich
</span>
) : null}
</button>
)
})}
</div>
</div>
{/* CENTER: Scrollable Category Selection */}
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden border-x border-white/5 px-4">
<div className="flex-1 overflow-y-auto pr-2 subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 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: Stepper Header + Summary sidebar */}
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden pl-4">
{/* Steps indicator at the top right */}
<div className="shrink-0 pb-4 border-b border-white/5 mb-4">
<p className="text-right text-slate-400 text-xs mb-2">Schritt 3 von 4 Software konfigurieren</p>
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
</div>
<div className="flex-1 overflow-y-auto pr-1">
<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>
)
}