Revert "feat: support product quantity and separate steps"
All checks were successful
Staging Build / build (push) Successful in 2m48s

This reverts commit 4ef21ea144.
This commit is contained in:
DanielS
2026-07-09 15:19:38 +02:00
parent 4ef21ea144
commit 915d3b1b2f
6 changed files with 259 additions and 396 deletions

View File

@@ -58,7 +58,6 @@ const productSchema = z.object({
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'), billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
show_in_kauf: z.boolean().default(true), show_in_kauf: z.boolean().default(true),
show_in_abo: z.boolean().default(true), show_in_abo: z.boolean().default(true),
has_quantity: z.boolean().default(false),
requirements: z.array(z.string()).default([]), requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]), exclusions: z.array(z.string()).default([]),
modules: z.array(moduleSchema).default([]), modules: z.array(moduleSchema).default([]),
@@ -92,7 +91,6 @@ export function CreateProductDialog({
billing_interval: product?.billing_interval || 'monthly', billing_interval: product?.billing_interval || 'monthly',
show_in_kauf: product ? (product.show_in_kauf ?? true) : true, show_in_kauf: product ? (product.show_in_kauf ?? true) : true,
show_in_abo: product ? (product.show_in_abo ?? true) : true, show_in_abo: product ? (product.show_in_abo ?? true) : true,
has_quantity: product ? (product.has_quantity ?? false) : false,
requirements: product?.requirements || [], requirements: product?.requirements || [],
exclusions: product?.exclusions || [], exclusions: product?.exclusions || [],
modules: product?.modules?.map(m => ({ modules: product?.modules?.map(m => ({
@@ -240,7 +238,7 @@ export function CreateProductDialog({
)} )}
/> />
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="show_in_kauf" name="show_in_kauf"
@@ -253,8 +251,8 @@ export function CreateProductDialog({
/> />
</FormControl> </FormControl>
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">In Kauf anzeigen</FormLabel> <FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
<FormDescription className="text-[10px] text-slate-500">Produkt im Kauf anzeigen</FormDescription> <FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
</div> </div>
</FormItem> </FormItem>
)} )}
@@ -271,26 +269,8 @@ export function CreateProductDialog({
/> />
</FormControl> </FormControl>
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">In Abo anzeigen</FormLabel> <FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
<FormDescription className="text-[10px] text-slate-500">Produkt im Abo anzeigen</FormDescription> <FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="has_quantity"
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 text-xs">Mengeneingabe</FormLabel>
<FormDescription className="text-[10px] text-slate-500">Mengeneingabe erlauben</FormDescription>
</div> </div>
</FormItem> </FormItem>
)} )}

View File

@@ -141,7 +141,7 @@ export function OrderWizard({
const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select') const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select')
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const [newCustomerForm, setNewCustomerForm] = useState({ const [newCustomerForm, setNewCustomerForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '', company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '', bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
}) })
@@ -162,16 +162,6 @@ export function OrderWizard({
return quantities return quantities
}) })
// Produktmengen (productId -> Menge)
const [productQuantities, setProductQuantities] = useState<Record<string, number>>(() => {
if (!initialOrder) return {}
const quantities: Record<string, number> = {}
initialOrder.order_data?.items?.forEach(item => {
quantities[item.product_id] = item.quantity ?? 1
})
return quantities
})
// Datum der letzten Lizenz (Bestandskunden-Update-Logik) // Datum der letzten Lizenz (Bestandskunden-Update-Logik)
const [lastLicenseDate, setLastLicenseDate] = useState<string>( const [lastLicenseDate, setLastLicenseDate] = useState<string>(
initialOrder?.order_data?.last_license_date ?? "" initialOrder?.order_data?.last_license_date ?? ""
@@ -338,7 +328,6 @@ export function OrderWizard({
sortedProds.forEach((prod, sortedIndex) => { sortedProds.forEach((prod, sortedIndex) => {
const base = sortedIndex < freeLimit ? 0 : Number(prod.base_price) const base = sortedIndex < freeLimit ? 0 : Number(prod.base_price)
const productQty = productQuantities[prod.id] || 1
let modulesSum = 0 let modulesSum = 0
sel.moduleIds.forEach(mId => { sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId) const mod = prod.modules?.find(m => m.id === mId)
@@ -347,7 +336,7 @@ export function OrderWizard({
modulesSum += Number(mod.price) * qty modulesSum += Number(mod.price) * qty
} }
}) })
const totalItem = (base + modulesSum) * productQty const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') { if (prod.billing_interval === 'one_time') {
oneTime += totalItem oneTime += totalItem
} else { } else {
@@ -475,14 +464,8 @@ export function OrderWizard({
const handleBillingIntervalChange = (val: 'one_time' | 'monthly') => { const handleBillingIntervalChange = (val: 'one_time' | 'monthly') => {
setSelectedBillingInterval(val) setSelectedBillingInterval(val)
setModuleQuantities({}) setModuleQuantities({})
setProductQuantities({})
if (val === 'monthly') { if (val === 'monthly') {
setLastLicenseDate("") setLastLicenseDate("")
if (!selectedEndCustomerId && endCustomers.length > 0) {
setSelectedEndCustomerId(endCustomers[0].id)
}
} else {
setSelectedEndCustomerId(null)
} }
const newSelections: Record<string, CategorySelection> = {} const newSelections: Record<string, CategorySelection> = {}
categories.forEach(cat => { categories.forEach(cat => {
@@ -514,7 +497,7 @@ export function OrderWizard({
setSelectedEndCustomerId(created.id) setSelectedEndCustomerId(created.id)
setCustomerMode('select') setCustomerMode('select')
setNewCustomerForm({ setNewCustomerForm({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '', company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '', bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
}) })
} catch (e: any) { } catch (e: any) {
@@ -533,7 +516,6 @@ export function OrderWizard({
order = await updateOrder(initialOrder.id, { order = await updateOrder(initialOrder.id, {
selections, selections,
moduleQuantities, moduleQuantities,
productQuantities,
customerProfile: customerData, customerProfile: customerData,
endCustomerId: selectedEndCustomerId, endCustomerId: selectedEndCustomerId,
endCustomer: selectedEndCustomer, endCustomer: selectedEndCustomer,
@@ -545,7 +527,6 @@ export function OrderWizard({
order = await submitOrder({ order = await submitOrder({
selections, selections,
moduleQuantities, moduleQuantities,
productQuantities,
products, products,
categories, categories,
customerProfile: customerData, customerProfile: customerData,
@@ -581,7 +562,7 @@ export function OrderWizard({
{step > s ? <Check className="w-6 h-6" /> : s} {step > s ? <Check className="w-6 h-6" /> : s}
</div> </div>
<span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'}`}> <span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'}`}>
{s === 1 ? 'Modell' : s === 2 ? 'Kunde' : s === 3 ? 'Software' : 'Abschluss'} {s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
</span> </span>
</div> </div>
))} ))}
@@ -589,7 +570,7 @@ export function OrderWizard({
</div> </div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{/* ───── Schritt 1: Abrechnungsmodell wählen ───── */} {/* ───── Schritt 1: Endkunde wählen / anlegen ───── */}
{step === 1 && ( {step === 1 && (
<motion.div <motion.div
key="step1" key="step1"
@@ -597,149 +578,18 @@ export function OrderWizard({
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
className="space-y-6" 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-end border-t border-white/10 pt-6">
<Button onClick={nextStep}>
Weiter zu den Stammdaten <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
</motion.div>
)}
{/* ───── Schritt 2: Stammdaten / Endkunde 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"> <Card className="glass-dark border-white/10">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white"> <CardTitle className="text-2xl flex items-center gap-2 text-white">
<User className="w-6 h-6 text-primary" /> <User className="w-6 h-6 text-primary" />
{selectedBillingInterval === 'one_time' ? 'Ihre Rechnungsdaten (Stammdaten)' : 'Für wen bestellen Sie?'} Für wen bestellen Sie?
</CardTitle> </CardTitle>
<CardDescription className="text-slate-300"> <CardDescription className="text-slate-300">
{selectedBillingInterval === 'one_time' Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an.
? 'Bestätigen Sie Ihre Firmen-Stammdaten für den Kauf.'
: 'Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{selectedBillingInterval === 'one_time' ? (
<div className="space-y-6 animate-in fade-in duration-300 text-left">
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
<h3 className="font-bold text-white text-base">Rechnungsempfänger Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-slate-300">
<div>
<p className="text-xs text-slate-500">Unternehmen</p>
<p className="font-semibold text-white">{customerData.company_name || 'Nicht angegeben'}</p>
</div>
<div>
<p className="text-xs text-slate-500">USt-IdNr.</p>
<p className="font-semibold text-white">{customerData.vat_id || 'Nicht angegeben'}</p>
</div>
<div>
<p className="text-xs text-slate-500">Ansprechpartner</p>
<p className="font-semibold text-white">{customerData.first_name} {customerData.last_name}</p>
</div>
<div>
<p className="text-xs text-slate-500">Adresse</p>
<p className="font-semibold text-white">
{customerData.address}<br />
{customerData.zip_code} {customerData.city}
</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 max-w-md">
<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 Sie bereits Lizenzen besitzen, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
</p>
</div>
</div>
) : (
<div className="space-y-6 animate-in fade-in duration-300">
{/* Admin Company Selector */} {/* Admin Company Selector */}
{isAdmin && ( {isAdmin && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3"> <div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
@@ -827,7 +677,7 @@ export function OrderWizard({
onChange={() => setSelectedEndCustomerId(customer.id)} onChange={() => setSelectedEndCustomerId(customer.id)}
className="sr-only" className="sr-only"
/> />
<div className="flex-1 text-left"> <div className="flex-1">
<p className="font-semibold text-white">{customer.company_name}</p> <p className="font-semibold text-white">{customer.company_name}</p>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')} {[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
@@ -849,7 +699,7 @@ export function OrderWizard({
{/* Modus B: Neuen Kunden anlegen */} {/* Modus B: Neuen Kunden anlegen */}
{customerMode === 'create' && ( {customerMode === 'create' && (
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10 text-left"> <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: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' }, { label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
@@ -858,7 +708,6 @@ export function OrderWizard({
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' }, { label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' }, { label: 'PLZ', key: 'zip', span: false, placeholder: '' },
{ label: 'Ort', key: 'city', span: false, placeholder: '' }, { label: 'Ort', key: 'city', span: false, placeholder: '' },
{ label: 'E-Mail Adresse', key: 'email', span: true, placeholder: 'kunden@firma.de' },
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' }, { label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' }, { label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' }, { label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
@@ -876,7 +725,6 @@ export function OrderWizard({
))} ))}
<div className="md:col-span-2 flex gap-2"> <div className="md:col-span-2 flex gap-2">
<Button <Button
type="button"
onClick={handleCreateCustomer} onClick={handleCreateCustomer}
disabled={isCreatingCustomer || !newCustomerForm.company_name.trim()} disabled={isCreatingCustomer || !newCustomerForm.company_name.trim()}
className="gap-2" className="gap-2"
@@ -890,6 +738,111 @@ export function OrderWizard({
</div> </div>
</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> </div>
)} )}
</CardContent> </CardContent>
@@ -897,16 +850,8 @@ export function OrderWizard({
<Button variant="ghost" className="text-white" onClick={prevStep}> <Button variant="ghost" className="text-white" onClick={prevStep}>
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück <ChevronLeft className="mr-2 w-4 h-4" /> Zurück
</Button> </Button>
<Button <Button onClick={nextStep}>
onClick={nextStep} Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
disabled={
selectedBillingInterval === 'monthly' && (
customerMode === 'create' ||
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
)
}
>
Weiter zur Software-Auswahl <ChevronRight className="ml-2 w-4 h-4" />
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -1048,29 +993,6 @@ export function OrderWizard({
</span> </span>
)} )}
</Label> </Label>
{isChecked && product.has_quantity && (
<div className="flex items-center gap-3 mt-3 pl-8 pb-3 pt-2 border-t border-white/5 mx-4">
<Label htmlFor={`prod-qty-${product.id}`} className="text-xs text-slate-400">Menge:</Label>
<Input
id={`prod-qty-${product.id}`}
type="number"
min={1}
max={999}
value={productQuantities[product.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setProductQuantities(prev => ({ ...prev, [product.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(product.base_price * (productQuantities[product.id] || 1))}
</span>
</div>
)}
</div> </div>
) )
})} })}
@@ -1081,9 +1003,7 @@ export function OrderWizard({
onValueChange={id => selectProduct(cat.id, id)} onValueChange={id => selectProduct(cat.id, id)}
className="grid gap-3" className="grid gap-3"
> >
{catProducts.map(product => { {catProducts.map(product => (
const isChecked = sel?.productId === product.id
return (
<div key={product.id} className="relative"> <div key={product.id} className="relative">
<RadioGroupItem <RadioGroupItem
value={product.id} value={product.id}
@@ -1118,32 +1038,8 @@ export function OrderWizard({
</span> </span>
)} )}
</Label> </Label>
{isChecked && product.has_quantity && (
<div className="flex items-center gap-3 mt-3 pl-8 pb-3 pt-2 border-t border-white/5 mx-4">
<Label htmlFor={`prod-qty-${product.id}`} className="text-xs text-slate-400">Menge:</Label>
<Input
id={`prod-qty-${product.id}`}
type="number"
min={1}
max={999}
value={productQuantities[product.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setProductQuantities(prev => ({ ...prev, [product.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(product.base_price * (productQuantities[product.id] || 1))}
</span>
</div> </div>
)} ))}
</div>
)
})}
</RadioGroup> </RadioGroup>
)} )}
@@ -1273,14 +1169,12 @@ export function OrderWizard({
{sortedProds.map((prod, idx) => { {sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price const actualPrice = isFree ? 0 : prod.base_price
const prodQty = productQuantities[prod.id] || 1
const qtyLabel = prod.has_quantity ? `(x${prodQty})` : ''
return ( return (
<div key={prod.id} className="space-y-0.5 pl-2"> <div key={prod.id} className="space-y-0.5 pl-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-white">{prod.name} {qtyLabel} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span> <span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
<span className="text-white"> <span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice * prodQty)} {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span> {' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span> </span>
</div> </div>
@@ -1289,9 +1183,9 @@ export function OrderWizard({
const qty = moduleQuantities[mId] || 1 const qty = moduleQuantities[mId] || 1
return mod ? ( return mod ? (
<div key={mId} className="flex justify-between text-xs pl-2"> <div key={mId} className="flex justify-between text-xs pl-2">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''} {prod.has_quantity ? `(x${prodQty} Art.)` : ''}</span> <span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300"> <span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty * prodQty)} {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span> </span>
</div> </div>
) : null ) : null
@@ -1456,21 +1350,19 @@ export function OrderWizard({
{sortedProds.map((prod, idx) => { {sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price const actualPrice = isFree ? 0 : prod.base_price
const prodQty = productQuantities[prod.id] || 1 const catTotal =
const baseTotal =
Number(actualPrice) + Number(actualPrice) +
(sel?.moduleIds?.reduce((acc, mId) => { (sel?.moduleIds?.reduce((acc, mId) => {
const mod = prod.modules?.find(m => m.id === mId) const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1 const qty = moduleQuantities[mId] || 1
return acc + (Number(mod?.price ?? 0) * qty) return acc + (Number(mod?.price ?? 0) * qty)
}, 0) || 0) }, 0) || 0)
const catTotal = baseTotal * prodQty
return ( return (
<div key={prod.id} className="pl-4"> <div key={prod.id} className="pl-4">
<div className="flex justify-between font-bold text-base text-white"> <div className="flex justify-between font-bold text-base text-white">
<span> <span>
{prod.name} {prod.has_quantity ? `(x${prodQty})` : ''} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>} {prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
</span> </span>
<span> <span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)} {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
@@ -1483,7 +1375,7 @@ export function OrderWizard({
.map(id => { .map(id => {
const mod = prod.modules?.find(m => m.id === id) const mod = prod.modules?.find(m => m.id === id)
const qty = moduleQuantities[id] || 1 const qty = moduleQuantities[id] || 1
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''} ${prod.has_quantity ? `(x${prodQty} Art.)` : ''}` : '' return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
}) })
.filter(Boolean) .filter(Boolean)
.join(', ')} .join(', ')}

View File

@@ -49,7 +49,6 @@ function hashOrderSnapshot(snapshot: object): string {
export async function submitOrder(params: { export async function submitOrder(params: {
selections: WizardSelections selections: WizardSelections
moduleQuantities?: Record<string, number> moduleQuantities?: Record<string, number>
productQuantities?: Record<string, number>
products?: Product[] products?: Product[]
categories?: Category[] categories?: Category[]
customerProfile: Partial<Profile> customerProfile: Partial<Profile>
@@ -58,7 +57,7 @@ export async function submitOrder(params: {
billingInterval?: 'one_time' | 'monthly' billingInterval?: 'one_time' | 'monthly'
lastLicenseDate?: string | null lastLicenseDate?: string | null
}): Promise<Order> { }): Promise<Order> {
const { selections, moduleQuantities, productQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
const supabase = await createClient() const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
@@ -86,7 +85,7 @@ export async function submitOrder(params: {
// 1. Snapshots aufbauen // 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate, productQuantities) const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 5 Minuten existiert // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 5 Minuten existiert
const orderHash = hashOrderSnapshot(orderSnapshot) const orderHash = hashOrderSnapshot(orderSnapshot)
@@ -367,7 +366,6 @@ export async function updateOrder(
params: { params: {
selections: WizardSelections selections: WizardSelections
moduleQuantities?: Record<string, number> moduleQuantities?: Record<string, number>
productQuantities?: Record<string, number>
customerProfile: Partial<Profile> customerProfile: Partial<Profile>
endCustomerId?: string | null endCustomerId?: string | null
endCustomer?: EndCustomer | null endCustomer?: EndCustomer | null
@@ -376,7 +374,7 @@ export async function updateOrder(
companyId?: string | null companyId?: string | null
} }
): Promise<Order> { ): Promise<Order> {
const { selections, moduleQuantities, productQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate, companyId } = params const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate, companyId } = params
const supabase = await createClient() const supabase = await createClient()
const admin = createAdminClient() const admin = createAdminClient()
@@ -436,7 +434,7 @@ export async function updateOrder(
// 1. Snapshots aufbauen // 1. Snapshots aufbauen
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate, productQuantities) const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
const orderHash = hashOrderSnapshot(orderSnapshot) const orderHash = hashOrderSnapshot(orderSnapshot)
// 2. Bestellung in DB aktualisieren // 2. Bestellung in DB aktualisieren

View File

@@ -91,8 +91,7 @@ export function buildOrderSnapshot(
categories: Category[], categories: Category[],
billingInterval?: 'one_time' | 'monthly', billingInterval?: 'one_time' | 'monthly',
moduleQuantities?: Record<string, number>, moduleQuantities?: Record<string, number>,
lastLicenseDate?: string | null, lastLicenseDate?: string | null
productQuantities?: Record<string, number>
): OrderSnapshot { ): OrderSnapshot {
const items: OrderItem[] = [] const items: OrderItem[] = []
let subtotal = 0 let subtotal = 0
@@ -136,12 +135,11 @@ export function buildOrderSnapshot(
} }
}) })
const productQty = productQuantities?.[prod.id] || 1
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0) const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
// If this product falls under the free limit, set its base price to 0 // If this product falls under the free limit, set its base price to 0
const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price
const itemTotal = (actualBasePrice + moduleTotal) * productQty const itemTotal = actualBasePrice + moduleTotal
subtotal += itemTotal subtotal += itemTotal
items.push({ items.push({
@@ -151,7 +149,6 @@ export function buildOrderSnapshot(
product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""), product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""),
base_price: actualBasePrice, base_price: actualBasePrice,
billing_interval: prod.billing_interval, billing_interval: prod.billing_interval,
quantity: productQty,
selected_modules: selectedModules, selected_modules: selectedModules,
item_total: itemTotal, item_total: itemTotal,
}) })

View File

@@ -17,7 +17,6 @@ export type Product = {
show_in_abo?: boolean show_in_abo?: boolean
requirements?: string[] requirements?: string[]
exclusions?: string[] exclusions?: string[]
has_quantity?: boolean
} }
export type Category = { export type Category = {
@@ -132,7 +131,6 @@ export type OrderItem = {
product_name: string product_name: string
base_price: number base_price: number
billing_interval: BillingInterval billing_interval: BillingInterval
quantity?: number
selected_modules: OrderModuleSnapshot[] selected_modules: OrderModuleSnapshot[]
item_total: number item_total: number
} }

View File

@@ -1,2 +0,0 @@
-- Add has_quantity column to products table
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS has_quantity BOOLEAN DEFAULT false NOT NULL;