1324 lines
68 KiB
TypeScript
1324 lines
68 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'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
type CategorySelection = {
|
|
productId: string | null // selected product in this category
|
|
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
|
|
)
|
|
|
|
// 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])
|
|
|
|
// Per-category selection map: categoryId -> { productId, moduleIds }
|
|
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
|
const init: Record<string, CategorySelection> = {}
|
|
categories.forEach(cat => {
|
|
if (initialOrder) {
|
|
const item = initialOrder.order_data?.items?.find(i => i.category_id === cat.id)
|
|
if (item) {
|
|
init[cat.id] = {
|
|
productId: item.product_id,
|
|
moduleIds: item.selected_modules?.map(m => m.module_id) ?? []
|
|
}
|
|
return
|
|
}
|
|
}
|
|
const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false)
|
|
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
|
|
})
|
|
return init
|
|
})
|
|
|
|
// Only REQUIRED categories must have a product selected (which must be filtered by selected branch)
|
|
const allCategoriesFilled = useMemo(() => {
|
|
return categories
|
|
.filter(cat => cat.is_required)
|
|
.every(cat => {
|
|
const sel = selections[cat.id]
|
|
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
|
|
})
|
|
}, [categories, selections, products, selectedBillingInterval])
|
|
|
|
// Product validation errors (requirements and exclusions)
|
|
const productValidationErrors = useMemo(() => {
|
|
const errors: string[] = []
|
|
const selectedProductIds = Object.values(selections)
|
|
.map(s => s.productId)
|
|
.filter((id): id is string => !!id)
|
|
|
|
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?.productId) continue
|
|
const prod = products.find(p => p.id === sel.productId)
|
|
if (!prod) continue
|
|
const base = 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 (resets modules)
|
|
function selectProduct(catId: string, productId: string) {
|
|
setSelections(prev => {
|
|
const next = {
|
|
...prev,
|
|
[catId]: { productId, moduleIds: [] },
|
|
}
|
|
|
|
// Automatically deselect now conflicting products in other categories
|
|
const selectedIds = Object.entries(next)
|
|
.map(([, s]) => s.productId)
|
|
.filter((id): id is string => !!id)
|
|
|
|
categories.forEach(c => {
|
|
if (c.id === catId) return
|
|
const sel = next[c.id]
|
|
if (!sel?.productId) return
|
|
|
|
const otherSelectedProductIds = selectedIds.filter(id => id !== sel.productId)
|
|
const prod = products.find(p => p.id === sel.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, moduleIds: [] }
|
|
}
|
|
})
|
|
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Helper: toggle module for a category's selected product
|
|
function toggleModule(catId: string, moduleId: string) {
|
|
setSelections(prev => {
|
|
const sel = prev[catId]
|
|
if (!sel?.productId) return prev
|
|
const prod = products.find(p => p.id === sel.productId)
|
|
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 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, moduleIds: [] }
|
|
})
|
|
setSelections(newSelections)
|
|
}
|
|
|
|
// Neuen Endkunden inline anlegen
|
|
const handleCreateCustomer = async () => {
|
|
if (!newCustomerForm.company_name.trim()) return
|
|
setIsCreatingCustomer(true)
|
|
try {
|
|
const created = await createEndCustomer(newCustomerForm)
|
|
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) {
|
|
alert('Fehler beim Anlegen des Kunden.')
|
|
} finally {
|
|
setIsCreatingCustomer(false)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (isSubmitting) return // Doppelklick-Guard
|
|
setIsSubmitting(true)
|
|
try {
|
|
let order
|
|
if (initialOrder) {
|
|
order = await updateOrder(initialOrder.id, {
|
|
selections,
|
|
moduleQuantities,
|
|
customerProfile: customerData,
|
|
endCustomerId: selectedEndCustomerId,
|
|
endCustomer: selectedEndCustomer,
|
|
billingInterval: selectedBillingInterval,
|
|
lastLicenseDate: lastLicenseDate || null,
|
|
companyId: selectedCompanyId,
|
|
})
|
|
} else {
|
|
order = await submitOrder({
|
|
selections,
|
|
moduleQuantities,
|
|
products,
|
|
categories,
|
|
customerProfile: customerData,
|
|
endCustomerId: selectedEndCustomerId,
|
|
endCustomer: selectedEndCustomer,
|
|
billingInterval: selectedBillingInterval,
|
|
lastLicenseDate: lastLicenseDate || null,
|
|
})
|
|
}
|
|
router.push(`/order/success?id=${order.id}`)
|
|
} catch (error: any) {
|
|
console.error(error)
|
|
alert(error?.message || (initialOrder ? 'Fehler beim Aktualisieren der Anfrage.' : 'Fehler bei der Bestellung. Bitte versuchen Sie es erneut.'))
|
|
setIsSubmitting(false) // Nur bei Fehler wieder entsperren
|
|
}
|
|
}
|
|
|
|
|
|
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 Ihrem CRM 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">
|
|
{categories.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 and mutual exclusions
|
|
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
|
|
if (!matchesInterval) return false
|
|
|
|
// Check if p is excluded by any other selected product
|
|
const isExcludedByOthers = otherSelectedProductIds.some(otherId => {
|
|
const otherProd = products.find(prod => prod.id === otherId)
|
|
return otherProd?.exclusions?.includes(p.id)
|
|
})
|
|
if (isExcludedByOthers) return false
|
|
|
|
// Check if p excludes any other selected product
|
|
const excludesOthers = p.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
|
|
if (excludesOthers) return false
|
|
|
|
return true
|
|
})
|
|
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?.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>
|
|
) : (
|
|
<RadioGroup
|
|
value={sel?.productId ?? ''}
|
|
onValueChange={id => selectProduct(cat.id, id)}
|
|
className="grid gap-3"
|
|
>
|
|
{catProducts.map(product => (
|
|
<div key={product.id} className="relative">
|
|
<RadioGroupItem
|
|
value={product.id}
|
|
id={`${cat.id}-${product.id}`}
|
|
className="peer sr-only"
|
|
/>
|
|
<Label
|
|
htmlFor={`${cat.id}-${product.id}`}
|
|
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
|
|
>
|
|
<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">
|
|
{categories.map(cat => {
|
|
const sel = selections[cat.id]
|
|
const prod = products.find(p => p.id === sel?.productId)
|
|
if (!prod) 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>
|
|
)
|
|
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>
|
|
<div className="flex justify-between text-sm pl-2">
|
|
<span className="text-white">{prod.name}</span>
|
|
<span className="text-white">
|
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
|
|
{' '}<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-4">
|
|
<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>
|
|
)
|
|
})}
|
|
<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>
|
|
))}
|
|
</CardContent>
|
|
<CardFooter className="flex flex-col gap-2">
|
|
<Button
|
|
className="w-full h-12 text-lg"
|
|
onClick={nextStep}
|
|
disabled={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-4">
|
|
{categories.map(cat => {
|
|
const sel = selections[cat.id]
|
|
const prod = products.find(p => p.id === sel?.productId)
|
|
if (!prod) return null
|
|
const catTotal =
|
|
Number(prod.base_price) +
|
|
sel.moduleIds.reduce((acc, mId) => {
|
|
const mod = prod.modules?.find(m => m.id === mId)
|
|
const qty = moduleQuantities[mId] || 1
|
|
return acc + (Number(mod?.price ?? 0) * qty)
|
|
}, 0)
|
|
return (
|
|
<div key={cat.id}>
|
|
<div className="flex justify-between font-bold text-base text-white">
|
|
<span className="flex items-center gap-2">
|
|
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
|
{prod.name}
|
|
</span>
|
|
<span>
|
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
|
|
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
|
|
</span>
|
|
</div>
|
|
{sel.moduleIds.length > 0 && (
|
|
<p className="text-sm text-slate-300 mt-1 pl-6">
|
|
Module: {sel.moduleIds
|
|
.map(id => {
|
|
const mod = prod.modules?.find(m => m.id === id)
|
|
const qty = moduleQuantities[id] || 1
|
|
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
|
|
})
|
|
.filter(Boolean)
|
|
.join(', ')}
|
|
</p>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|