feat: restructure checkout steps, add Kauf/Abo selection and product display flags
All checks were successful
Staging Build / build (push) Successful in 2m41s
All checks were successful
Staging Build / build (push) Successful in 2m41s
This commit is contained in:
@@ -55,6 +55,8 @@ const productSchema = z.object({
|
||||
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
||||
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
|
||||
show_in_kauf: z.boolean().default(true),
|
||||
show_in_abo: z.boolean().default(true),
|
||||
modules: z.array(moduleSchema).default([]),
|
||||
})
|
||||
|
||||
@@ -72,6 +74,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
base_price: product?.base_price || 0,
|
||||
category_id: product?.category_id || '',
|
||||
billing_interval: product?.billing_interval || 'monthly',
|
||||
show_in_kauf: product ? (product.show_in_kauf ?? true) : true,
|
||||
show_in_abo: product ? (product.show_in_abo ?? true) : true,
|
||||
modules: product?.modules?.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -217,6 +221,45 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 } from 'lucide-react'
|
||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2, CreditCard, Calendar } from 'lucide-react'
|
||||
import { submitOrder } from '@/lib/actions/orders'
|
||||
import { createEndCustomer } from '@/lib/actions/end-customers'
|
||||
import { Category } from '@/lib/types'
|
||||
@@ -107,23 +107,35 @@ export function OrderWizard({
|
||||
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
|
||||
})
|
||||
|
||||
// Abrechnungsmodell-Auswahl ('one_time' = Kauf, 'monthly' = Abo)
|
||||
const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>('monthly')
|
||||
|
||||
// Per-category selection map: categoryId -> { productId, moduleIds }
|
||||
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
||||
const init: Record<string, CategorySelection> = {}
|
||||
categories.forEach(cat => {
|
||||
const first = products.find(p => p.category_id === cat.id)
|
||||
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
|
||||
const allCategoriesFilled = useMemo(
|
||||
() => categories.filter(cat => cat.is_required).every(cat => !!selections[cat.id]?.productId),
|
||||
[categories, selections]
|
||||
)
|
||||
// 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])
|
||||
|
||||
// Total price across all selections
|
||||
// Total price calculations (split by billing interval)
|
||||
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||
let monthly = 0
|
||||
@@ -171,6 +183,20 @@ export function OrderWizard({
|
||||
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)
|
||||
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
|
||||
@@ -199,6 +225,7 @@ export function OrderWizard({
|
||||
customerProfile: customerData,
|
||||
endCustomerId: selectedEndCustomerId,
|
||||
endCustomer: selectedEndCustomer,
|
||||
billingInterval: selectedBillingInterval,
|
||||
})
|
||||
router.push(`/order/success?id=${order.id}`)
|
||||
} catch (error) {
|
||||
@@ -212,24 +239,30 @@ export function OrderWizard({
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-12 px-4">
|
||||
{/* Progress Stepper */}
|
||||
<div className="flex justify-between mb-12 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].map(s => (
|
||||
<div
|
||||
key={s}
|
||||
className={`relative z-10 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>
|
||||
))}
|
||||
<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">
|
||||
{/* ───── Step 1: Product Selection per Category ───── */}
|
||||
{/* ───── Schritt 1: Endkunde wählen / anlegen ───── */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
@@ -237,13 +270,240 @@ export function OrderWizard({
|
||||
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">
|
||||
{/* Modus-Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('select')}
|
||||
className="gap-2 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 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.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-2">
|
||||
{endCustomers.filter(c => !c.is_anonymized).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>
|
||||
)}
|
||||
|
||||
{/* 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: '' },
|
||||
] 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 text-white"
|
||||
>
|
||||
{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
|
||||
className="text-white"
|
||||
onClick={nextStep}
|
||||
disabled={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>
|
||||
</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 className="text-white" 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">
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<ShoppingCart className="w-6 h-6 text-primary" />
|
||||
Software wählen
|
||||
</CardTitle>
|
||||
@@ -253,7 +513,13 @@ export function OrderWizard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{categories.map((cat, idx) => {
|
||||
const catProducts = products.filter(p => p.category_id === cat.id)
|
||||
// Filter products based on display flags show_in_kauf / show_in_abo
|
||||
const catProducts = products.filter(p => {
|
||||
if (p.category_id !== cat.id) return false
|
||||
return selectedBillingInterval === 'one_time'
|
||||
? p.show_in_kauf !== false
|
||||
: p.show_in_abo !== false
|
||||
})
|
||||
const sel = selections[cat.id]
|
||||
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||
|
||||
@@ -289,7 +555,7 @@ export function OrderWizard({
|
||||
|
||||
{catProducts.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm italic">
|
||||
Keine Produkte in dieser Kategorie vorhanden.
|
||||
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<RadioGroup
|
||||
@@ -479,14 +745,17 @@ export function OrderWizard({
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
className="w-full h-12 text-lg text-white"
|
||||
onClick={nextStep}
|
||||
disabled={!allCategoriesFilled}
|
||||
>
|
||||
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>
|
||||
@@ -494,151 +763,10 @@ export function OrderWizard({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ───── Step 2: Endkunde wählen / anlegen ───── */}
|
||||
{step === 2 && (
|
||||
{/* ───── Schritt 4: Prüfung & Abschluss ───── */}
|
||||
{step === 4 && (
|
||||
<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">
|
||||
<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">
|
||||
{/* Modus-Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('select')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Building2 className="w-4 h-4" /> Bestandskunde wählen
|
||||
</Button>
|
||||
<Button
|
||||
variant={customerMode === 'create' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('create')}
|
||||
className="gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modus A: Bestandskunde wählen */}
|
||||
{customerMode === 'select' && (
|
||||
<div className="space-y-4">
|
||||
{endCustomers.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">
|
||||
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{endCustomers.filter(c => !c.is_anonymized).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>
|
||||
)}
|
||||
|
||||
{/* 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: '' },
|
||||
] 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" onClick={() => setCustomerMode('select')}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||
<Button variant="ghost" onClick={prevStep}>
|
||||
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId}
|
||||
>
|
||||
Prüfung & Abschluss <ChevronRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ───── Step 3: Review & Submit ───── */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
key="step4"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -751,7 +879,7 @@ export function OrderWizard({
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button
|
||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90 text-white"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@@ -761,7 +889,7 @@ export function OrderWizard({
|
||||
'Kostenpflichtig bestellen'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={prevStep} className="w-full">
|
||||
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||
Noch etwas ändern
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
@@ -55,8 +55,9 @@ export async function submitOrder(params: {
|
||||
customerProfile: Partial<Profile>
|
||||
endCustomerId?: string | null
|
||||
endCustomer?: EndCustomer | null
|
||||
billingInterval?: 'one_time' | 'monthly'
|
||||
}): Promise<Order> {
|
||||
const { selections, customerProfile, endCustomerId, endCustomer } = params
|
||||
const { selections, customerProfile, endCustomerId, endCustomer, billingInterval } = params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
@@ -105,7 +106,7 @@ export async function submitOrder(params: {
|
||||
// 1. Snapshots aufbauen
|
||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
||||
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
|
||||
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval)
|
||||
|
||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
|
||||
@@ -84,7 +84,8 @@ export function buildCustomerSnapshot(
|
||||
export function buildOrderSnapshot(
|
||||
selections: WizardSelections,
|
||||
products: Product[],
|
||||
categories: Category[]
|
||||
categories: Category[],
|
||||
billingInterval?: 'one_time' | 'monthly'
|
||||
): OrderSnapshot {
|
||||
const items: OrderItem[] = []
|
||||
let subtotal = 0
|
||||
@@ -127,10 +128,10 @@ export function buildOrderSnapshot(
|
||||
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
|
||||
const total = Math.round((subtotal + taxAmount) * 100) / 100
|
||||
|
||||
// Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly'
|
||||
const billingCycle = items.every((i) => i.billing_interval === 'one_time')
|
||||
// Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln
|
||||
const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time')
|
||||
? 'one_time'
|
||||
: 'monthly'
|
||||
: 'monthly')
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
|
||||
@@ -13,6 +13,8 @@ export type Product = {
|
||||
category?: Category | null
|
||||
modules?: ProductModule[]
|
||||
created_at: string
|
||||
show_in_kauf?: boolean
|
||||
show_in_abo?: boolean
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add show_in_kauf and show_in_abo columns to products table
|
||||
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_kauf BOOLEAN DEFAULT true;
|
||||
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_abo BOOLEAN DEFAULT true;
|
||||
Reference in New Issue
Block a user