feat: Add quantity support for product modules, multiply prices in order snapshot and pdf
Some checks failed
Staging Build / build (push) Has been cancelled

This commit is contained in:
DanielS
2026-06-25 13:04:37 +02:00
parent fe05e13d3f
commit 9e189f331a
8 changed files with 159 additions and 66 deletions

View File

@@ -45,6 +45,7 @@ const moduleSchema = z.object({
description: z.string().optional(),
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
is_required: z.boolean().default(false),
has_quantity: z.boolean().default(false),
requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]),
})
@@ -82,6 +83,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
description: m.description || '',
price: m.price,
is_required: false,
has_quantity: m.has_quantity ?? false,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
})) || [],
@@ -291,6 +293,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
name: '',
price: 0,
is_required: false,
has_quantity: false,
description: '',
requirements: [],
exclusions: []
@@ -442,21 +445,35 @@ export function CreateProductDialog({ children, categories, product }: { childre
/>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-6">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<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">Erforderlich</FormLabel>
</div>
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Erforderlich</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.has_quantity`}
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Mengeneingabe erlauben</FormLabel>
</FormItem>
)}
/>

View File

@@ -153,18 +153,25 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>+ {mod.module_name}</Text>
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
const hasQty = qty > 1;
return (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
</Text>
</View>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
</Text>
</View>
</View>
))}
);
})}
</View>
))}
</View>

View File

@@ -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>