Revert "feat: support product quantity and separate steps"
All checks were successful
Staging Build / build (push) Successful in 2m48s
All checks were successful
Staging Build / build (push) Successful in 2m48s
This reverts commit 4ef21ea144.
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,6 +578,190 @@ 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">
|
||||||
|
<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">
|
<Card className="glass-dark border-white/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -662,234 +827,22 @@ export function OrderWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
|
||||||
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
<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">
|
||||||
<Button onClick={nextStep}>
|
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
|
||||||
Weiter zu den Stammdaten <ChevronRight className="ml-2 w-4 h-4" />
|
<Calendar className="w-4 h-4 text-primary" />
|
||||||
</Button>
|
Datum der letzten CAS-Lizenz (falls vorhanden)
|
||||||
</CardFooter>
|
</Label>
|
||||||
</Card>
|
<Input
|
||||||
</motion.div>
|
id="last-license-date"
|
||||||
)}
|
type="date"
|
||||||
|
value={lastLicenseDate}
|
||||||
{/* ───── Schritt 2: Stammdaten / Endkunde wählen ───── */}
|
onChange={e => setLastLicenseDate(e.target.value)}
|
||||||
{step === 2 && (
|
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
|
||||||
<motion.div
|
/>
|
||||||
key="step2"
|
<p className="text-xs text-slate-400">
|
||||||
initial={{ opacity: 0, x: 20 }}
|
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
|
||||||
animate={{ opacity: 1, x: 0 }}
|
</p>
|
||||||
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" />
|
|
||||||
{selectedBillingInterval === 'one_time' ? 'Ihre Rechnungsdaten (Stammdaten)' : 'Für wen bestellen Sie?'}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-slate-300">
|
|
||||||
{selectedBillingInterval === 'one_time'
|
|
||||||
? '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>
|
|
||||||
</CardHeader>
|
|
||||||
<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 */}
|
|
||||||
{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 text-left">
|
|
||||||
<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 text-left">
|
|
||||||
{([
|
|
||||||
{ 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: 'E-Mail Adresse', key: 'email', span: true, placeholder: 'kunden@firma.de' },
|
|
||||||
{ 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
|
|
||||||
type="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>
|
|
||||||
)}
|
|
||||||
</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,69 +1003,43 @@ 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
|
<div key={product.id} className="relative">
|
||||||
return (
|
<RadioGroupItem
|
||||||
<div key={product.id} className="relative">
|
value={product.id}
|
||||||
<RadioGroupItem
|
id={`${cat.id}-${product.id}`}
|
||||||
value={product.id}
|
className="peer sr-only"
|
||||||
id={`${cat.id}-${product.id}`}
|
/>
|
||||||
className="peer sr-only"
|
<Label
|
||||||
/>
|
htmlFor={`${cat.id}-${product.id}`}
|
||||||
<Label
|
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"
|
||||||
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">
|
||||||
<div className="flex justify-between w-full items-center">
|
<span className="font-bold text-base text-white">{product.name}</span>
|
||||||
<div className="flex items-center gap-2">
|
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||||
<span className="font-bold text-base text-white">{product.name}</span>
|
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||||
<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>
|
|
||||||
{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>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
{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>
|
</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(', ')}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user