feat: Add quantity support for product modules, multiply prices in order snapshot and pdf
Some checks failed
Staging Build / build (push) Has been cancelled
Some checks failed
Staging Build / build (push) Has been cancelled
This commit is contained in:
@@ -110,6 +110,9 @@ export function OrderWizard({
|
||||
// Abrechnungsmodell-Auswahl ('one_time' = Kauf, 'monthly' = Abo)
|
||||
const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>('monthly')
|
||||
|
||||
// Modulmengen (moduleId -> Menge)
|
||||
const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>({})
|
||||
|
||||
// Per-category selection map: categoryId -> { productId, moduleIds }
|
||||
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
||||
const init: Record<string, CategorySelection> = {}
|
||||
@@ -149,7 +152,10 @@ export function OrderWizard({
|
||||
let modulesSum = 0
|
||||
sel.moduleIds.forEach(mId => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (mod) modulesSum += Number(mod.price)
|
||||
if (mod) {
|
||||
const qty = moduleQuantities[mId] || 1
|
||||
modulesSum += Number(mod.price) * qty
|
||||
}
|
||||
})
|
||||
const totalItem = base + modulesSum
|
||||
if (prod.billing_interval === 'one_time') {
|
||||
@@ -159,7 +165,7 @@ export function OrderWizard({
|
||||
}
|
||||
}
|
||||
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
|
||||
}, [selections, products, categories])
|
||||
}, [selections, products, categories, moduleQuantities])
|
||||
|
||||
// Helper: update product selection for a category (resets modules)
|
||||
function selectProduct(catId: string, productId: string) {
|
||||
@@ -178,6 +184,13 @@ export function OrderWizard({
|
||||
const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId)
|
||||
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
|
||||
})
|
||||
setModuleQuantities(prev => {
|
||||
if (prev[moduleId] !== undefined) {
|
||||
const { [moduleId]: _, ...rest } = prev
|
||||
return rest
|
||||
}
|
||||
return { ...prev, [moduleId]: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
const nextStep = () => setStep(s => s + 1)
|
||||
@@ -186,6 +199,7 @@ export function OrderWizard({
|
||||
// Abrechnungsmodell wechseln (und Selektionen der Kategorien anpassen)
|
||||
const handleBillingIntervalChange = (val: 'one_time' | 'monthly') => {
|
||||
setSelectedBillingInterval(val)
|
||||
setModuleQuantities({})
|
||||
const newSelections: Record<string, CategorySelection> = {}
|
||||
categories.forEach(cat => {
|
||||
const matchingProd = products.find(p =>
|
||||
@@ -220,6 +234,7 @@ export function OrderWizard({
|
||||
try {
|
||||
const order = await submitOrder({
|
||||
selections,
|
||||
moduleQuantities,
|
||||
products,
|
||||
categories,
|
||||
customerProfile: customerData,
|
||||
@@ -612,40 +627,67 @@ export function OrderWizard({
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`flex items-start space-x-3 p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
|
||||
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-${cat.id}-${module.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`mod-${cat.id}-${module.id}`}
|
||||
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span>{module.name}</span>
|
||||
<span className="text-primary font-bold">
|
||||
+{new Intl.NumberFormat('de-DE', {
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`mod-${cat.id}-${module.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`mod-${cat.id}-${module.id}`}
|
||||
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span>{module.name}</span>
|
||||
<span className="text-primary font-bold">
|
||||
+{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}
|
||||
</span>
|
||||
</Label>
|
||||
{module.description && (
|
||||
<p className="text-xs text-slate-400">{module.description}</p>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{module.requirements?.some(
|
||||
reqId => !sel?.moduleIds.includes(reqId)
|
||||
)
|
||||
? 'Benötigt weitere Module'
|
||||
: 'Nicht kombinierbar mit aktueller Auswahl'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scalable Quantity Input */}
|
||||
{checked && module.has_quantity && (
|
||||
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
|
||||
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
|
||||
<Input
|
||||
id={`qty-${module.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={moduleQuantities[module.id] || 1}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, parseInt(e.target.value) || 1)
|
||||
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
|
||||
}}
|
||||
className="w-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
Gesamt: {new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}
|
||||
}).format(module.price * (moduleQuantities[module.id] || 1))}
|
||||
</span>
|
||||
</Label>
|
||||
{module.description && (
|
||||
<p className="text-xs text-slate-400">{module.description}</p>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{module.requirements?.some(
|
||||
reqId => !sel?.moduleIds.includes(reqId)
|
||||
)
|
||||
? 'Benötigt weitere Module'
|
||||
: 'Nicht kombinierbar mit aktueller Auswahl'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -692,11 +734,12 @@ export function OrderWizard({
|
||||
</div>
|
||||
{sel.moduleIds.map(mId => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
const qty = moduleQuantities[mId] || 1
|
||||
return mod ? (
|
||||
<div key={mId} className="flex justify-between text-xs pl-4">
|
||||
<span className="text-slate-300">+ {mod.name}</span>
|
||||
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
|
||||
<span className="text-slate-300">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
@@ -791,7 +834,8 @@ export function OrderWizard({
|
||||
Number(prod.base_price) +
|
||||
sel.moduleIds.reduce((acc, mId) => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
return acc + Number(mod?.price ?? 0)
|
||||
const qty = moduleQuantities[mId] || 1
|
||||
return acc + (Number(mod?.price ?? 0) * qty)
|
||||
}, 0)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
@@ -808,7 +852,11 @@ export function OrderWizard({
|
||||
{sel.moduleIds.length > 0 && (
|
||||
<p className="text-sm text-slate-300 mt-1 pl-6">
|
||||
Module: {sel.moduleIds
|
||||
.map(id => prod.modules?.find(m => m.id === id)?.name)
|
||||
.map(id => {
|
||||
const mod = prod.modules?.find(m => m.id === id)
|
||||
const qty = moduleQuantities[id] || 1
|
||||
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user