All checks were successful
Staging Build / build (push) Successful in 2m55s
Add collapsible LicenseLookupPanel component to step 3 of the order wizard (left column). Panel queries mock LicServer data by license key and displays product, customer, dates, status badge and licensed modules. Real API endpoint can replace mock in lookupLicense(). - New: components/wizard/license-lookup-panel.tsx - Modified: components/order-wizard.tsx (4-col grid)
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="max-w-5xl mx-auto px-4">
|
|
{/* 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-4 gap-6 items-start">
|
|
|
|
{/* FAR LEFT: License Lookup Panel (sticky) */}
|
|
<div className="lg:col-span-1 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-1 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>
|
|
)
|
|
}
|