From 9e189f331ab476b6557758b634f8a51eaf12e889 Mon Sep 17 00:00:00 2001 From: DanielS Date: Thu, 25 Jun 2026 13:04:37 +0200 Subject: [PATCH] feat: Add quantity support for product modules, multiply prices in order snapshot and pdf --- .../admin/create-product-dialog.tsx | 27 +++- shop/components/invoice-pdf.tsx | 29 +++-- shop/components/order-wizard.tsx | 120 ++++++++++++------ shop/lib/actions/orders.ts | 5 +- shop/lib/actions/products.ts | 2 + shop/lib/license-transform.ts | 36 ++++-- shop/lib/types.ts | 4 + ...00_add_has_quantity_to_product_modules.sql | 2 + 8 files changed, 159 insertions(+), 66 deletions(-) create mode 100644 shop/supabase/migrations/20260625130000_add_has_quantity_to_product_modules.sql diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx index 78a99c0..1947a51 100644 --- a/shop/components/admin/create-product-dialog.tsx +++ b/shop/components/admin/create-product-dialog.tsx @@ -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 /> -
+
( - + -
- Erforderlich -
+ Erforderlich +
+ )} + /> + + ( + + + + + Mengeneingabe erlauben )} /> diff --git a/shop/components/invoice-pdf.tsx b/shop/components/invoice-pdf.tsx index 6e1b74e..e4739fd 100644 --- a/shop/components/invoice-pdf.tsx +++ b/shop/components/invoice-pdf.tsx @@ -153,18 +153,25 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => { - {item.selected_modules?.map((mod: any, mIdx: number) => ( - - - + {mod.module_name} + {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 ( + + + + + {mod.module_name} {hasQty ? `(x${qty})` : ''} + + + + + +{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)} + + - - - +{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)} - - - - ))} + ); + })} ))} diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index b4cdf48..7631989 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -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>({}) + // Per-category selection map: categoryId -> { productId, moduleIds } const [selections, setSelections] = useState>(() => { const init: Record = {} @@ -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 = {} 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 (
- toggleModule(cat.id, module.id)} - disabled={disabled} - /> -
-
+ )}
) })} @@ -692,11 +734,12 @@ export function OrderWizard({
{sel.moduleIds.map(mId => { const mod = prod.modules?.find(m => m.id === mId) + const qty = moduleQuantities[mId] || 1 return mod ? (
- + {mod.name} + + {mod.name} {mod.has_quantity ? `(x${qty})` : ''} - {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)}
) : 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 (
@@ -808,7 +852,11 @@ export function OrderWizard({ {sel.moduleIds.length > 0 && (

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(', ')}

diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index a23662e..76cce69 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -50,6 +50,7 @@ function hashOrderSnapshot(snapshot: object): string { */ export async function submitOrder(params: { selections: WizardSelections + moduleQuantities?: Record products?: Product[] categories?: Category[] customerProfile: Partial @@ -57,7 +58,7 @@ export async function submitOrder(params: { endCustomer?: EndCustomer | null billingInterval?: 'one_time' | 'monthly' }): Promise { - const { selections, customerProfile, endCustomerId, endCustomer, billingInterval } = params + const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval } = params const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -106,7 +107,7 @@ export async function submitOrder(params: { // 1. Snapshots aufbauen // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) - const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval) + const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities) // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert const orderHash = hashOrderSnapshot(orderSnapshot) diff --git a/shop/lib/actions/products.ts b/shop/lib/actions/products.ts index f8bad36..c063e71 100644 --- a/shop/lib/actions/products.ts +++ b/shop/lib/actions/products.ts @@ -89,6 +89,7 @@ export async function createProduct( price: m.price, requirements: m.requirements || [], exclusions: m.exclusions || [], + has_quantity: m.has_quantity ?? false, })) const { error: modulesError } = await supabase .from('product_modules') @@ -125,6 +126,7 @@ export async function updateProduct(id: string, product: Partial, modul price: m.price, requirements: m.requirements || [], exclusions: m.exclusions || [], + has_quantity: m.has_quantity ?? false, })) const { error: modulesError } = await supabase .from('product_modules') diff --git a/shop/lib/license-transform.ts b/shop/lib/license-transform.ts index e96905f..41f601f 100644 --- a/shop/lib/license-transform.ts +++ b/shop/lib/license-transform.ts @@ -85,7 +85,8 @@ export function buildOrderSnapshot( selections: WizardSelections, products: Product[], categories: Category[], - billingInterval?: 'one_time' | 'monthly' + billingInterval?: 'one_time' | 'monthly', + moduleQuantities?: Record ): OrderSnapshot { const items: OrderItem[] = [] let subtotal = 0 @@ -103,13 +104,18 @@ export function buildOrderSnapshot( const selectedModules = sel.moduleIds .map((modId) => prod.modules?.find((m) => m.id === modId)) .filter((m): m is NonNullable => !!m) - .map((mod) => ({ - module_id: mod.id, - module_name: mod.name, - price: mod.price, - })) + .map((mod) => { + const qty = moduleQuantities?.[mod.id] || 1 + return { + module_id: mod.id, + module_name: mod.name, + price: mod.price, + quantity: qty, + total_price: mod.price * qty, + } + }) - const moduleTotal = selectedModules.reduce((acc, m) => acc + m.price, 0) + const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0) const itemTotal = prod.base_price + moduleTotal subtotal += itemTotal @@ -157,7 +163,8 @@ export function transformToLicenseBundle( selections: WizardSelections, products: Product[], categories: Category[], - customer: Partial + customer: Partial, + moduleQuantities?: Record ): LicenseBundle { const options: LicenseOption[] = [] let monthlyTotal = 0 @@ -186,6 +193,7 @@ export function transformToLicenseBundle( for (const modId of sel.moduleIds) { const mod = prod.modules?.find((m) => m.id === modId) if (!mod) continue + const qty = moduleQuantities?.[mod.id] || 1 options.push({ key: `${prodKey}__${slugify(mod.name)}`, @@ -194,10 +202,11 @@ export function transformToLicenseBundle( // Module erben das Billing-Intervall des übergeordneten Produkts billing: prod.billing_interval, price_net: mod.price, + quantity: qty, }) - if (prod.billing_interval === 'monthly') monthlyTotal += mod.price - else oneTimeTotal += mod.price + if (prod.billing_interval === 'monthly') monthlyTotal += mod.price * qty + else oneTimeTotal += mod.price * qty } } @@ -238,16 +247,19 @@ export function licenseBundleFromSnapshot( else oneTimeTotal += item.base_price for (const mod of item.selected_modules) { + const qty = mod.quantity || 1 + const modPrice = mod.total_price || (mod.price * qty) options.push({ key: `${prodKey}__${slugify(mod.module_name)}`, label: mod.module_name, active: true, billing: item.billing_interval, price_net: mod.price, + quantity: qty, }) - if (item.billing_interval === 'monthly') monthlyTotal += mod.price - else oneTimeTotal += mod.price + if (item.billing_interval === 'monthly') monthlyTotal += modPrice + else oneTimeTotal += modPrice } } diff --git a/shop/lib/types.ts b/shop/lib/types.ts index ac078a9..c05b35d 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -36,6 +36,7 @@ export type ProductModule = { requirements: string[] // UUID[] anderer Module, die aktiv sein müssen exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen created_at: string + has_quantity?: boolean } export type Profile = { @@ -103,6 +104,8 @@ export type OrderModuleSnapshot = { module_id: string module_name: string price: number + quantity?: number + total_price?: number } export type OrderItem = { @@ -154,6 +157,7 @@ export type LicenseOption = { active: boolean billing: BillingInterval price_net: number + quantity?: number } export type LicenseBundle = { diff --git a/shop/supabase/migrations/20260625130000_add_has_quantity_to_product_modules.sql b/shop/supabase/migrations/20260625130000_add_has_quantity_to_product_modules.sql new file mode 100644 index 0000000..13688a8 --- /dev/null +++ b/shop/supabase/migrations/20260625130000_add_has_quantity_to_product_modules.sql @@ -0,0 +1,2 @@ +-- Add has_quantity column to product_modules table +ALTER TABLE public.product_modules ADD COLUMN IF NOT EXISTS has_quantity BOOLEAN DEFAULT false;