Files
webshop/shop/components/order-wizard.tsx
DanielS 98b1301e96
All checks were successful
Staging Build / build (push) Successful in 2m50s
fix: resolve typescript implicit any type errors in order-wizard.tsx
2026-07-09 23:35:59 +02:00

1643 lines
84 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Product, ProductModule, Profile, EndCustomer, Order } from '@/lib/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2, CreditCard, Calendar, Search } from 'lucide-react'
import { submitOrder, updateOrder } from '@/lib/actions/orders'
import { createEndCustomer } from '@/lib/actions/end-customers'
import { Category } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
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
}
// ─── Billing interval helpers ─────────────────────────────────────────────────
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'
}
// ─── Icon helper ──────────────────────────────────────────────────────────────
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const Icon = icon === 'Cloud' ? Cloud : icon === 'Utensils' ? Utensils : icon === 'HardDrive' ? HardDrive : Package
return <Icon className={className} />
}
// ─── Module toggle logic ───────────────────────────────────────────────────────
function toggleModuleInList(
modules: ProductModule[],
currentIds: string[],
toggleId: string
): string[] {
const isSelected = currentIds.includes(toggleId)
if (isSelected) {
// Recursive removal: repeatedly filter until no more dependent modules are removed
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
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
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[]>([])
const [deviceName, setDeviceName] = useState<string>('')
// 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 filteredEndCustomers = useMemo(() => {
const activeCustomers = endCustomers.filter(c => !c.is_anonymized)
const term = searchTerm.toLowerCase().trim()
if (!term) return activeCustomers
return activeCustomers.filter(customer => {
const company = customer.company_name?.toLowerCase() || ''
const firstName = customer.first_name?.toLowerCase() || ''
const lastName = customer.last_name?.toLowerCase() || ''
const street = customer.street?.toLowerCase() || ''
const zip = customer.zip?.toLowerCase() || ''
const city = customer.city?.toLowerCase() || ''
return (
company.includes(term) ||
firstName.includes(term) ||
lastName.includes(term) ||
street.includes(term) ||
zip.includes(term) ||
city.includes(term)
)
})
}, [endCustomers, searchTerm])
// Modus: 'select' = Bestandskunde wählen | 'create' = Neuen anlegen
const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select')
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const [newCustomerForm, setNewCustomerForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
})
// Abrechnungsmodell-Auswahl ('one_time' = Kauf, 'monthly' = Abo)
const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>(
initialOrder?.order_data?.billing_cycle ?? 'monthly'
)
// Modulmengen (moduleId -> Menge)
const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>(() => {
if (!initialOrder) return {}
const quantities: Record<string, number> = {}
initialOrder.order_data?.items?.forEach(item => {
item.selected_modules?.forEach(mod => {
quantities[mod.module_id] = mod.quantity ?? 1
})
})
return quantities
})
// Datum der letzten Lizenz (Bestandskunden-Update-Logik)
const [lastLicenseDate, setLastLicenseDate] = useState<string>(
initialOrder?.order_data?.last_license_date ?? ""
)
const lastLicenseMonths = useMemo(() => {
if (!lastLicenseDate || customerMode !== 'select' || !selectedEndCustomerId) return null
const lastDate = new Date(lastLicenseDate)
const today = new Date()
const yearsDiff = today.getFullYear() - lastDate.getFullYear()
const monthsDiff = today.getMonth() - lastDate.getMonth()
return yearsDiff * 12 + monthsDiff
}, [lastLicenseDate, customerMode, selectedEndCustomerId])
const updatePriceModifier = useMemo(() => {
if (lastLicenseMonths === null || selectedBillingInterval === 'monthly') {
return { multiplier: 1, discount: 0, label: null }
}
if (lastLicenseMonths <= 12) {
return { multiplier: 0, discount: 1, label: "Update (0-12 Mon.): 100% Rabatt (inklusive)" }
} else if (lastLicenseMonths <= 14) {
return { multiplier: 0.15, discount: 0.85, label: "Update (13-14 Mon.): 15% vom Listenpreis" }
} else if (lastLicenseMonths <= 25) {
return { multiplier: 0.30, discount: 0.70, label: "Update (15-25 Mon.): 30% vom Listenpreis" }
} else if (lastLicenseMonths <= 37) {
return { multiplier: 0.45, discount: 0.55, label: "Update (26-37 Mon.): 45% vom Listenpreis" }
} else if (lastLicenseMonths <= 49) {
return { multiplier: 0.60, discount: 0.40, label: "Update (38-49 Mon.): 60% vom Listenpreis" }
} else {
return { multiplier: 0.90, discount: 0.10, label: "Neukauf (>= 50 Mon.): 10% Rabatt" }
}
}, [lastLicenseMonths, selectedBillingInterval])
const visibleCategories = useMemo(() => {
return categories.filter(cat =>
selectedBillingInterval === 'one_time'
? cat.show_in_kauf !== false
: cat.show_in_abo !== false
)
}, [categories, selectedBillingInterval])
const isProductDisabled = (product: Product, currentCategoryId: string) => {
const baseCategoryId = visibleCategories[0]?.id
if (!baseCategoryId || currentCategoryId === baseCategoryId) return false
const baseProductId = selections[baseCategoryId]?.productId
if (!baseProductId) return false
const baseProduct = products.find(p => p.id === baseProductId)
if (!baseProduct) return false
return baseProduct.exclusions?.includes(product.id) || product.exclusions?.includes(baseProductId)
}
// Per-category selection map: categoryId -> { productId, productIds, moduleIds }
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const initInterval = initialOrder?.order_data?.billing_cycle ?? 'monthly'
const init: Record<string, CategorySelection> = {}
categories.forEach(cat => {
if (initialOrder) {
// Multi-select might have multiple items matching this category
const matchingItems = initialOrder.order_data?.items?.filter(i => i.category_id === cat.id) || []
if (matchingItems.length > 0) {
init[cat.id] = {
productId: matchingItems[0].product_id,
productIds: matchingItems.map(i => i.product_id),
moduleIds: matchingItems.flatMap(i => i.selected_modules?.map(m => m.module_id) ?? [])
}
return
}
}
const isVisible = initInterval === 'one_time' ? cat.show_in_kauf !== false : cat.show_in_abo !== false
if (!isVisible || cat.allow_multiselect || cat.preselect === false) {
init[cat.id] = { productId: null, productIds: [], moduleIds: [] }
} else {
const first = products.find(p => p.category_id === cat.id && (initInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false))
init[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
}
})
return init
})
// Only REQUIRED categories must have a product selected (which must be filtered by selected branch)
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
// Ensure the selected product is actually allowed in the current branch
return selectedBillingInterval === 'one_time'
? prod.show_in_kauf !== false
: prod.show_in_abo !== false
})
}, [visibleCategories, selections, products, selectedBillingInterval])
// Product validation errors (requirements and exclusions)
const productValidationErrors = useMemo(() => {
const errors: string[] = []
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])
const isNextStepDisabled = !allCategoriesFilled || productValidationErrors.length > 0
// 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)
}
if (selectedProds.length === 0) continue
// Apply free items limit: sort selected products by base_price ascending so that the cheapest are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
sortedProds.forEach((prod, sortedIndex) => {
const base = sortedIndex < freeLimit ? 0 : Number(prod.base_price)
let modulesSum = 0
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) {
const qty = moduleQuantities[mId] || 1
modulesSum += Number(mod.price) * qty
}
})
const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') {
oneTime += totalItem
} else {
monthly += totalItem
}
})
}
// Apply update modifier to oneTime total
if (updatePriceModifier.multiplier !== 1) {
oneTime = oneTime * updatePriceModifier.multiplier
}
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
}, [selections, products, categories, moduleQuantities, updatePriceModifier])
const oneTimeNet = oneTimeTotal
const oneTimeTax = oneTimeNet * 0.19
const oneTimeGross = oneTimeNet + oneTimeTax
const monthlyNet = monthlyTotal
const monthlyTax = monthlyNet * 0.19
const monthlyGross = monthlyNet + monthlyTax
// Helper: update product selection for a category
function selectProduct(catId: string, productId: string) {
const cat = categories.find(c => c.id === catId)
setSelections(prev => {
const sel = prev[catId]
let nextProductIds = sel?.productIds || []
let nextProductId = sel?.productId || null
if (cat?.allow_multiselect) {
if (nextProductIds.includes(productId)) {
nextProductIds = nextProductIds.filter(id => id !== productId)
} else {
nextProductIds = [...nextProductIds, productId]
}
nextProductId = nextProductIds[0] || null
} else {
nextProductIds = [productId]
nextProductId = productId
}
const isSelectedNow = cat?.allow_multiselect
? nextProductIds.includes(productId)
: nextProductId === productId
const shouldReset = isSelectedNow && cat?.resets_others
const next = {
...prev,
[catId]: { productId: nextProductId, productIds: nextProductIds, moduleIds: [] },
}
// Kaskaden-Bereinigung: Entferne inkompatible Module in anderen Kategorien
const newProduct = products.find(p => p.id === nextProductId)
categories.forEach(c => {
if (c.id === catId) return
const s = next[c.id]
if (s && s.moduleIds.length > 0) {
s.moduleIds = s.moduleIds.filter(mId => {
// Prüfe ob das neue Basis-Produkt dieses Modul ausschließt
const isExcluded = newProduct?.exclusions?.includes(mId)
return !isExcluded
})
}
})
if (shouldReset) {
// Reset all other categories
categories.forEach(c => {
if (c.id !== catId) {
next[c.id] = { productId: null, productIds: [], moduleIds: [] }
}
})
} else {
// Automatically deselect now conflicting products in other categories
const selectedIds: string[] = []
Object.values(next).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
s.productIds.forEach(id => {
if (id) selectedIds.push(id)
})
} else if (s.productId) {
selectedIds.push(s.productId)
}
})
categories.forEach(c => {
if (c.id === catId) return
const s = next[c.id]
if (c.allow_multiselect && s?.productIds) {
const validProductIds = s.productIds.filter(pId => {
const prod = products.find(p => p.id === pId)
const otherSelectedProductIds = selectedIds.filter(id => id !== pId)
const isExcluded = prod && (
otherSelectedProductIds.some(otherId => {
const otherProd = products.find(p => p.id === otherId)
return otherProd?.exclusions?.includes(prod.id)
}) ||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
)
return !isExcluded
})
next[c.id] = {
productId: validProductIds[0] || null,
productIds: validProductIds,
moduleIds: s.moduleIds
}
} else if (s?.productId) {
const prod = products.find(p => p.id === s.productId)
const otherSelectedProductIds = selectedIds.filter(id => id !== s.productId)
const isExcluded = prod && (
otherSelectedProductIds.some(otherId => {
const otherProd = products.find(p => p.id === otherId)
return otherProd?.exclusions?.includes(prod.id)
}) ||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
)
if (isExcluded) {
next[c.id] = { productId: null, productIds: [], moduleIds: [] }
}
}
})
}
return next
})
}
// Helper: toggle module for a category's selected product
function toggleModule(catId: string, moduleId: string) {
setSelections(prev => {
const sel = prev[catId]
// Pick first selected product to associate module with (or fall back to single select productId)
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 = () => setStep(s => s + 1)
const prevStep = () => setStep(s => s - 1)
// Abrechnungsmodell wechseln (und Selektionen der Kategorien anpassen)
const handleBillingIntervalChange = (val: 'one_time' | 'monthly') => {
setSelectedBillingInterval(val)
setModuleQuantities({})
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) {
alert(`Fehler beim Anlegen des Kunden: ${e.message || 'Unbekannter Fehler'}`)
} finally {
setIsCreatingCustomer(false)
}
}
const addToBasket = () => {
const currentItem = {
deviceName: deviceName || `Kasse ${basketItems.length + 1}`,
selections: JSON.parse(JSON.stringify(selections)),
moduleQuantities: { ...moduleQuantities },
billingInterval: selectedBillingInterval,
}
setBasketItems(prev => [...prev, currentItem])
setModuleQuantities({})
setDeviceName('')
// Reset selections
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 handleSubmit = async () => {
if (isSubmitting) return
setIsSubmitting(true)
try {
let finalItems = [...basketItems]
if (finalItems.length === 0 && allCategoriesFilled && productValidationErrors.length === 0) {
finalItems.push({
deviceName: deviceName || 'Kasse 1',
selections,
moduleQuantities,
billingInterval: selectedBillingInterval
})
}
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)
alert(error?.message || 'Fehler bei der Bestellung. Bitte versuchen Sie es erneut.')
setIsSubmitting(false)
}
}
const finalItemsToShow = basketItems.length > 0
? basketItems
: (allCategoriesFilled && productValidationErrors.length === 0
? [{ deviceName: deviceName || 'Kasse 1', selections, moduleQuantities, billingInterval: selectedBillingInterval }]
: []);
return (
<div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */}
<div className="max-w-md mx-auto mb-12">
<div className="flex justify-between relative">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
{[1, 2, 3, 4].map(s => (
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${step >= s
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
: 'bg-slate-800 text-slate-400'
}`}
>
{step > s ? <Check className="w-6 h-6" /> : s}
</div>
<span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'}`}>
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
</span>
</div>
))}
</div>
</div>
<AnimatePresence mode="wait">
{/* ───── Schritt 1: Endkunde wählen / anlegen ───── */}
{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"
>
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<User className="w-6 h-6 text-primary" />
Für wen bestellen Sie?
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie einen Endkunden aus oder legen Sie einen neuen an.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Admin Company Selector */}
{isAdmin && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary" />
Zugeordnetes Unternehmen (Admin-Option)
</Label>
<select
id="order-company"
value={selectedCompanyId || ''}
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
>
<option value="">Keine Firma zugewiesen</option>
{companies.map((c: any) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
)}
{/* Modus-Toggle */}
<div className="flex gap-2">
<Button
variant={customerMode === 'select' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('select')}
className={`gap-2 ${customerMode === 'select' ? '' : 'text-white'}`}
>
<Building2 className="w-4 h-4" /> Bestandskunde wählen
</Button>
<Button
variant={customerMode === 'create' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('create')}
className={`gap-2 ${customerMode === 'create' ? '' : 'text-white'}`}
>
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
</Button>
</div>
{/* Modus A: Bestandskunde wählen */}
{customerMode === 'select' && (
<div className="space-y-4">
{endCustomers.filter(c => !c.is_anonymized).length === 0 ? (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10 text-white">
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
</Button>
</div>
) : (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Kunden suchen nach Name, Ort, PLZ..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
{filteredEndCustomers.length === 0 ? (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
<p className="text-slate-400">Keine passenden Kunden gefunden.</p>
</div>
) : (
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{filteredEndCustomers.map(customer => (
<label
key={customer.id}
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${selectedEndCustomerId === customer.id
? 'border-primary bg-primary/5'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<input
type="radio"
name="end_customer"
value={customer.id}
checked={selectedEndCustomerId === customer.id}
onChange={() => setSelectedEndCustomerId(customer.id)}
className="sr-only"
/>
<div className="flex-1">
<p className="font-semibold text-white">{customer.company_name}</p>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
{customer.first_name || customer.last_name ? ' · ' : ''}
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
</p>
</div>
{selectedEndCustomerId === customer.id && (
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
)}
</label>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Modus B: Neuen Kunden anlegen */}
{customerMode === 'create' && (
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
{([
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
] as const).map(({ label, key, span, placeholder }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>
<Input
value={newCustomerForm[key]}
onChange={e => setNewCustomerForm(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
))}
<div className="md:col-span-2 flex gap-2">
<Button
onClick={handleCreateCustomer}
disabled={isCreatingCustomer || !newCustomerForm.company_name.trim()}
className="gap-2"
>
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Kunden speichern & auswählen
</Button>
<Button variant="ghost" className="text-white" onClick={() => setCustomerMode('select')}>
Abbrechen
</Button>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
<Button
onClick={nextStep}
disabled={
customerMode === 'create' ||
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
}
>
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
</motion.div>
)}
{/* ───── Schritt 2: Abrechnungsmodell wählen ───── */}
{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"
>
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<CreditCard className="w-6 h-6 text-primary" />
Abrechnungsmodell wählen
</CardTitle>
<CardDescription className="text-slate-300">
Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten?
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6 py-4">
{/* Option 1: Kauf */}
<div
onClick={() => handleBillingIntervalChange('one_time')}
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${selectedBillingInterval === 'one_time'
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div>
<div className="flex items-center gap-3 mb-3">
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'one_time' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
<CreditCard className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-white">Einmaliger Kauf</h3>
</div>
<p className="text-slate-400 text-sm">
Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren.
</p>
</div>
{selectedBillingInterval === 'one_time' && (
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
<Check className="w-4 h-4" />
</div>
)}
</div>
{/* Option 2: Abo */}
<div
onClick={() => handleBillingIntervalChange('monthly')}
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${selectedBillingInterval === 'monthly'
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div>
<div className="flex items-center gap-3 mb-3">
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'monthly' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
<Calendar className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-white">Monatliches Abo (Miete)</h3>
</div>
<p className="text-slate-400 text-sm">
Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit.
</p>
</div>
{selectedBillingInterval === 'monthly' && (
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
<Check className="w-4 h-4" />
</div>
)}
</div>
</div>
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-6 max-w-md animate-in fade-in slide-in-from-top-2 duration-300">
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
Datum der letzten CAS-Lizenz (falls vorhanden)
</Label>
<Input
id="last-license-date"
type="date"
value={lastLicenseDate}
onChange={e => setLastLicenseDate(e.target.value)}
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
/>
<p className="text-xs text-slate-400">
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
<Button variant="ghost" className="text-white" onClick={prevStep}>
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
</Button>
<Button onClick={nextStep}>
Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
</motion.div>
)}
{/* ───── Schritt 3: Software wählen ───── */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div className="grid md:grid-cols-3 gap-6">
{/* Left: Category sections */}
<div className="md:col-span-2 space-y-8">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<ShoppingCart className="w-6 h-6 text-primary" />
Software wählen
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{visibleCategories.map((cat, idx) => {
const otherSelectedProductIds = Object.entries(selections)
.filter(([cId]) => cId !== cat.id)
.map(([, s]) => s.productId)
.filter((id): id is string => !!id)
// Filter products based on display flags (do not hide due to exclusions to prevent UI deadlocks)
const catProducts = products.filter(p => {
if (p.category_id !== cat.id) return false
const matchesInterval = selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false
: p.show_in_abo !== false
return matchesInterval
})
const sel = selections[cat.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<div key={cat.id}>
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
{/* Category header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
{cat.description && (
<p className="text-slate-400 text-xs">{cat.description}</p>
)}
</div>
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : cat.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-80">
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
Optional
</Badge>
)}
</div>
{catProducts.length === 0 ? (
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : cat.allow_multiselect ? (
<div className="grid gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
const disabled = isProductDisabled(product, cat.id)
return (
<div key={product.id} className="relative">
<Label
onClick={() => !disabled && selectProduct(cat.id, product.id)}
className={`flex flex-col items-start p-4 rounded-xl border-2 transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-50 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/5 cursor-pointer'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-3">
<Checkbox
checked={isChecked}
disabled={disabled}
onCheckedChange={() => { }} // onClick on label handles it
className="border-white/20 data-[state=checked]:bg-primary"
/>
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1 pl-7">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(cat.id, id)}
className="grid gap-3"
>
{catProducts.map(product => {
const disabled = isProductDisabled(product, cat.id)
return (
<div key={product.id} className="relative">
<RadioGroupItem
value={product.id}
id={`${cat.id}-${product.id}`}
disabled={disabled}
className="peer sr-only"
/>
<Label
htmlFor={disabled ? undefined : `${cat.id}-${product.id}`}
className={`flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 transition-all ${disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-2">
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
)}
{/* Modules for selected product in this category */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
{selectedProduct.modules.map(module => {
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = sel?.moduleIds.includes(module.id) ?? false
return (
<div
key={module.id}
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
>
<div className="flex items-start space-x-3">
<Checkbox
id={`mod-${cat.id}-${module.id}`}
checked={checked}
onCheckedChange={() => toggleModule(cat.id, module.id)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`mod-${cat.id}-${module.id}`}
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary font-bold">
+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400">{module.description}</p>
)}
{disabled && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
{/* Scalable Quantity Input */}
{checked && module.has_quantity && (
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
<Input
id={`qty-${module.id}`}
type="number"
min={1}
max={999}
value={moduleQuantities[module.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
}}
className="w-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
/>
<span className="text-xs text-slate-500">
Gesamt: {new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price * (moduleQuantities[module.id] || 1))}
</span>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</CardContent>
</Card>
</div>
{/* Right: Summary */}
<div className="space-y-6">
<Card className="glass-dark border-primary/20 sticky top-6">
<CardHeader>
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
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)
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className="w-3 h-3 inline-block" />
)}
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
// Sort selected products by price to determine which ones are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
return (
<div key={prod.id} className="space-y-0.5 pl-2">
<div className="flex justify-between text-sm">
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-2">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span>
</div>
) : null
})}
</div>
)
})}
</div>
)
})}
<Separator className="bg-white/10" />
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmalig:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlich:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-gradient">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-bold text-gradient">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
)}
{updatePriceModifier.label && (
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
<p className="font-semibold flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
Update-Rabatt aktiv
</p>
<p>{updatePriceModifier.label}</p>
</div>
)}
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p>
)}
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-destructive" />
{err}
</p>
))}
{basketItems.length > 0 && (
<div className="pt-4 border-t border-white/10 space-y-2">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Warenkorb ({basketItems.length}):</p>
{basketItems.map((item, idx) => (
<div key={idx} className="flex justify-between text-xs bg-white/5 p-2 rounded border border-white/10">
<span className="text-white font-medium">{item.deviceName}</span>
<span className="text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
</div>
))}
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-3">
<div className="w-full space-y-2">
<Label htmlFor="device-name" className="text-xs text-slate-400">Gerätename (optional)</Label>
<Input
id="device-name"
placeholder="z.B. Hauptkasse, Theke"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-9 rounded-lg"
/>
</div>
<Button
type="button"
variant="outline"
className="w-full border-primary/30 text-white hover:bg-primary/10 gap-2 h-10 text-sm"
onClick={addToBasket}
disabled={isNextStepDisabled}
>
<UserPlus className="w-4 h-4" /> Noch eine Kasse hinzufügen
</Button>
<Separator className="bg-white/10 my-1" />
<Button
className="w-full h-12 text-lg"
onClick={nextStep}
disabled={basketItems.length === 0 && isNextStepDisabled}
>
Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
</div>
</div>
</motion.div>
)}
{/* ───── Schritt 4: Prüfung & Abschluss ───── */}
{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"
>
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
<CardHeader>
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-3xl text-white">Anfrage prüfen</CardTitle>
<CardDescription className="text-slate-300">
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 text-left">
<div className="p-4 rounded-lg bg-white/5 space-y-6">
{finalItemsToShow.map((item, itemIdx) => (
<div key={itemIdx} className="space-y-3 border-b border-white/10 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center bg-white/5 p-2 rounded">
<span className="text-white font-bold text-sm">Gerät: {item.deviceName}</span>
<span className="text-xs text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
</div>
{visibleCategories.map(cat => {
const sel = item.selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
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)
}
if (selectedProds.length === 0) return null
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1 pl-2">
<div className="text-slate-400 font-semibold text-xs flex items-center gap-1">
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary" />
<span>{cat.name}</span>
</div>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
const catTotal =
Number(actualPrice) +
(sel?.moduleIds?.reduce((acc: number, mId: string) => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = item.moduleQuantities[mId] || 1
return acc + (Number(mod?.price ?? 0) * qty)
}, 0) || 0)
return (
<div key={prod.id} className="pl-3">
<div className="flex justify-between font-semibold text-sm text-white">
<span>
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
</span>
</div>
{sel?.moduleIds && sel.moduleIds.length > 0 && (
<p className="text-xs text-slate-400 mt-0.5">
Module: {sel.moduleIds
.map((id: string) => {
const mod = prod.modules?.find(m => m.id === id)
const qty = item.moduleQuantities[id] || 1
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
})
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
)
})}
</div>
)
})}
</div>
))}
<Separator className="bg-white/10" />
<div className="text-sm text-slate-300 space-y-1">
{selectedEndCustomer ? (
<>
<p className="font-semibold text-white flex items-center gap-2">
<Building2 className="w-3.5 h-3.5 text-primary" />
{selectedEndCustomer.company_name}
</p>
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
{lastLicenseDate && (
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-primary" />
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
</p>
)}
</>
) : (
<>
<p className="font-semibold text-white">{customerData.company_name}</p>
<p>{customerData.first_name} {customerData.last_name}</p>
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
</>
)}
</div>
</div>
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-4 border-t border-white/10 pt-4">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag:</p>
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag:</p>
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1 border-t border-white/10 pt-4">
<div className="flex justify-between text-sm text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="flex justify-between text-xl font-bold text-gradient pt-1 border-t border-white/5">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1 border-t border-white/10 pt-4">
<div className="flex justify-between text-sm text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-xl font-bold text-gradient pt-1 border-t border-white/5">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
)}
<p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
Datenschutzerklärung
</a>.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
initialOrder ? (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird aktualisiert...</>
) : (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird versendet...</>
)
) : (
initialOrder ? 'Anfrage aktualisieren' : 'Anfrage versenden'
)}
</Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
Noch etwas ändern
</Button>
</CardFooter>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
)
}