diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx index a63e599..73c016e 100644 --- a/shop/components/admin/create-product-dialog.tsx +++ b/shop/components/admin/create-product-dialog.tsx @@ -29,7 +29,7 @@ import { Plus, Trash2, X, PlusCircle } from 'lucide-react' import { createProduct, updateProduct } from '@/lib/actions/products' import { ScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' -import { Category } from '@/lib/types' +import { Category, Product } from '@/lib/types' import { Select, SelectContent, @@ -52,7 +52,6 @@ const productSchema = z.object({ description: z.string().optional(), base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'), - is_active: z.boolean().default(true), billing_interval: z.enum(['one_time', 'monthly']).default('monthly'), modules: z.array(moduleSchema).default([]), }) @@ -69,13 +68,12 @@ export function CreateProductDialog({ children, categories, product }: { childre description: product?.description || '', base_price: product?.base_price || 0, category_id: product?.category_id || '', - is_active: product?.is_active ?? true, billing_interval: product?.billing_interval || 'monthly', modules: product?.modules?.map(m => ({ name: m.name, description: m.description || '', price: m.price, - is_required: m.is_required, + is_required: false, requirements: m.requirements?.join(', ') || '', exclusions: m.exclusions?.join(', ') || '', })) || [], diff --git a/shop/components/admin/product-list.tsx b/shop/components/admin/product-list.tsx index 3979282..36a9fe0 100644 --- a/shop/components/admin/product-list.tsx +++ b/shop/components/admin/product-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { Product } from '@/lib/types' +import { Product, Category } from '@/lib/types' import { Table, TableBody, @@ -16,7 +16,6 @@ import { Button } from '@/components/ui/button' import { deleteProduct } from '@/lib/actions/products' import { useState } from 'react' import { CreateProductDialog } from './create-product-dialog' -import { Category } from '@/lib/types' export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) { const [products, setProducts] = useState(initialProducts) @@ -77,8 +76,8 @@ export function ProductList({ initialProducts, categories }: { initialProducts: - - {product.is_active ? "Aktiv" : "Inaktiv"} + + {product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'} diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 8b0861d..5e38173 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -146,29 +146,11 @@ export function OrderWizard({ const handleSubmit = async () => { try { - // Build multi-product order payload – submit each category item - const items = categories - .map(cat => { - const sel = selections[cat.id] - if (!sel?.productId) return null - const prod = products.find(p => p.id === sel.productId)! - return { - category: cat.name, - product_id: sel.productId, - product_name: prod.name, - base_price: prod.base_price, - selected_modules: sel.moduleIds, - } - }) - .filter(Boolean) - await submitOrder({ - product_id: items[0]!.product_id, // primary (first category) - selected_modules: items.flatMap(i => i!.selected_modules), - total_amount: totalPrice, - customer_details: customerData, - // @ts-ignore extended payload - items, + selections, + products, + categories, + customerProfile: customerData, }) alert('Bestellung erfolgreich aufgegeben!') window.location.href = '/' @@ -178,6 +160,7 @@ export function OrderWizard({ } } + return (
{/* Progress Stepper */} diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index fd48449..1703d36 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -5,89 +5,74 @@ import { revalidatePath } from 'next/cache' import { InvoicePDF } from '@/components/invoice-pdf' import { renderToBuffer } from '@react-pdf/renderer' import React from 'react' +import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform' +import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types' -export async function submitOrder(orderData: { - product_id: string; - selected_modules: string[]; - total_amount: number; - customer_details: any; -}) { +// ─── submitOrder ────────────────────────────────────────────────────────────── + +/** + * Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB, + * generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch. + */ +export async function submitOrder(params: { + selections: WizardSelections + products: Product[] + categories: Category[] + customerProfile: Partial +}): Promise { + const { selections, products, categories, customerProfile } = params const supabase = await createClient() - + const { data: { user } } = await supabase.auth.getUser() if (!user) throw new Error('Not authenticated') - // 1. Fetch Product and Module Details for Invoice - const { data: product } = await supabase - .from('products') - .select('*') - .eq('id', orderData.product_id) - .single() + // 1. Snapshots aufbauen + const customerSnapshot = buildCustomerSnapshot(customerProfile) + const orderSnapshot = buildOrderSnapshot(selections, products, categories) - const { data: modules } = await supabase - .from('product_modules') - .select('*') - .in('id', orderData.selected_modules) - - if (!product) throw new Error('Product not found') - - // 2. Create the Order in DB + // 2. Bestellung in DB schreiben const { data: order, error: orderError } = await supabase .from('orders') .insert([{ user_id: user.id, - total_price: orderData.total_amount, // Matched column name in initial_schema.sql - order_data: { - product_id: orderData.product_id, - modules: orderData.selected_modules - }, - customer_data: orderData.customer_details, - status: 'pending' + total_price: orderSnapshot.total, + customer_data: customerSnapshot, + order_data: orderSnapshot, + status: 'pending', }]) .select() .single() if (orderError) throw orderError - // 3. Generate PDF + // 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog) try { const buffer = await renderToBuffer( React.createElement(InvoicePDF, { - order: order, - product: product, - modules: modules || [], - customer: orderData.customer_details + order, + customer: customerSnapshot, + orderSnapshot, }) ) - // 4. Upload to Supabase Storage + // 4. PDF in Supabase Storage hochladen const fileName = `invoice_${order.id}.pdf` - const { data: uploadData, error: uploadError } = await supabase + const { error: uploadError } = await supabase .storage .from('invoices') - .upload(fileName, buffer, { - contentType: 'application/pdf', - upsert: true - }) + .upload(fileName, buffer, { contentType: 'application/pdf', upsert: true }) if (uploadError) { console.error('PDF Upload Error:', uploadError) } else { - // 5. Update Order with PDF URL - const { data: publicUrlData } = supabase - .storage - .from('invoices') - .getPublicUrl(fileName) - - await supabase - .from('orders') - .update({ pdf_url: publicUrlData.publicUrl }) - .eq('id', order.id) + const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName) + await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id) } } catch (pdfError) { console.error('PDF Generation Error:', pdfError) } revalidatePath('/protected') - return order + return order as Order } + diff --git a/shop/lib/actions/products.ts b/shop/lib/actions/products.ts index ffb8542..bf4f218 100644 --- a/shop/lib/actions/products.ts +++ b/shop/lib/actions/products.ts @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { revalidatePath } from 'next/cache' -import { Product, ProductModule } from '../types' +import { Category, Product, ProductModule } from '../types' export async function getProducts() { const supabase = await createClient() @@ -109,7 +109,6 @@ export async function updateProduct(id: string, product: Partial, modul name: m.name, description: m.description || null, price: m.price, - is_required: m.is_required, requirements: m.requirements || [], exclusions: m.exclusions || [], product_id: id diff --git a/shop/lib/license-transform.ts b/shop/lib/license-transform.ts new file mode 100644 index 0000000..bd3106a --- /dev/null +++ b/shop/lib/license-transform.ts @@ -0,0 +1,242 @@ +/** + * license-transform.ts + * + * Transformationsschicht: Wandelt den Wizard-Zustand in strukturierte + * Datenstrukturen für DB-Speicherung (OrderSnapshot) und Lizenzserver (LicenseBundle) um. + */ + +import type { + Category, + CategorySelection, + CustomerSnapshot, + LicenseBundle, + LicenseOption, + OrderItem, + OrderSnapshot, + Product, + Profile, + WizardSelections, +} from '@/lib/types' + +// ─── Hilfsfunktionen ───────────────────────────────────────────────────────── + +/** + * Wandelt einen Produktnamen in einen stabilen, URL-sicheren Slug um. + * Beispiel: "CASPOS Basic" → "caspos_basic" + */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_|_$/g, '') +} + +/** + * Baut einen validen CustomerSnapshot aus einem Profil oder Formulardaten. + * Fehlende Pflichtfelder werden mit leerem String aufgefüllt (kein throw – + * Validierung findet im Wizard-UI statt). + */ +export function buildCustomerSnapshot(profile: Partial): CustomerSnapshot { + return { + schema_version: 1, + company_name: profile.company_name ?? '', + vat_id: profile.vat_id ?? '', + first_name: profile.first_name ?? '', + last_name: profile.last_name ?? '', + address: profile.address ?? '', + zip_code: profile.zip_code ?? '', + city: profile.city ?? '', + ...(profile.email ? { email: profile.email } : {}), + } +} + +// ─── OrderSnapshot ──────────────────────────────────────────────────────────── + +/** + * Baut den unveränderlichen OrderSnapshot für die JSONB-Spalte `orders.order_data`. + * + * WICHTIG: Alle Namen und Preise werden hier eingefroren (Snapshot-Prinzip). + * Spätere Änderungen am Produktkatalog brechen keine historischen Bestellungen. + */ +export function buildOrderSnapshot( + selections: WizardSelections, + products: Product[], + categories: Category[] +): OrderSnapshot { + const items: OrderItem[] = [] + let subtotal = 0 + let dominantTaxRate = 19 // Fallback; wird durch das erste Produkt mit tax_rate überschrieben + + for (const cat of categories) { + const sel: CategorySelection | undefined = selections[cat.id] + if (!sel?.productId) continue + + const prod = products.find((p) => p.id === sel.productId) + if (!prod) continue + + dominantTaxRate = prod.tax_rate + + 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, + })) + + const moduleTotal = selectedModules.reduce((acc, m) => acc + m.price, 0) + const itemTotal = prod.base_price + moduleTotal + subtotal += itemTotal + + items.push({ + category_id: cat.id, + category_name: cat.name, + product_id: prod.id, + product_name: prod.name, + base_price: prod.base_price, + billing_interval: prod.billing_interval, + selected_modules: selectedModules, + item_total: itemTotal, + }) + } + + const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100 + const total = Math.round((subtotal + taxAmount) * 100) / 100 + + // Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly' + const billingCycle = items.every((i) => i.billing_interval === 'one_time') + ? 'one_time' + : 'monthly' + + return { + schema_version: 1, + billing_cycle: billingCycle, + items, + subtotal, + tax_rate: dominantTaxRate, + tax_amount: taxAmount, + total, + } +} + +// ─── LicenseBundle ──────────────────────────────────────────────────────────── + +/** + * Transformiert einen gespeicherten OrderSnapshot (oder den aktuellen Wizard-Zustand) + * in ein LicenseBundle für den Lizenzserver / das ERP-System. + * + * Jedes Produkt und jedes Modul wird auf einen stabilen `key` (Slug) gemappt, + * der als Identifier im Lizenzserver verwendet werden kann. + */ +export function transformToLicenseBundle( + selections: WizardSelections, + products: Product[], + categories: Category[], + customer: Partial +): LicenseBundle { + const options: LicenseOption[] = [] + let monthlyTotal = 0 + let oneTimeTotal = 0 + + for (const cat of categories) { + const sel: CategorySelection | undefined = selections[cat.id] + if (!sel?.productId) continue + + const prod = products.find((p) => p.id === sel.productId) + if (!prod) continue + + const prodKey = slugify(prod.name) + + options.push({ + key: prodKey, + label: prod.name, + active: true, + billing: prod.billing_interval, + price_net: prod.base_price, + }) + + if (prod.billing_interval === 'monthly') monthlyTotal += prod.base_price + else oneTimeTotal += prod.base_price + + for (const modId of sel.moduleIds) { + const mod = prod.modules?.find((m) => m.id === modId) + if (!mod) continue + + options.push({ + key: `${prodKey}__${slugify(mod.name)}`, + label: mod.name, + active: true, + // Module erben das Billing-Intervall des übergeordneten Produkts + billing: prod.billing_interval, + price_net: mod.price, + }) + + if (prod.billing_interval === 'monthly') monthlyTotal += mod.price + else oneTimeTotal += mod.price + } + } + + return { + customer_ref: customer.company_name ?? customer.last_name ?? 'unknown', + generated_at: new Date().toISOString(), + license_key_prefix: `CASP-${new Date().getFullYear()}-`, + options, + monthly_total_net: monthlyTotal, + one_time_total_net: oneTimeTotal, + } +} + +/** + * Rekonstruiert ein LicenseBundle direkt aus einem gespeicherten OrderSnapshot + * (ohne Zugriff auf den Live-Katalog – gut für historische Bestellungen). + */ +export function licenseBundleFromSnapshot( + snapshot: OrderSnapshot, + customerRef: string +): LicenseBundle { + const options: LicenseOption[] = [] + let monthlyTotal = 0 + let oneTimeTotal = 0 + + for (const item of snapshot.items) { + const prodKey = slugify(item.product_name) + + options.push({ + key: prodKey, + label: item.product_name, + active: true, + billing: item.billing_interval, + price_net: item.base_price, + }) + + if (item.billing_interval === 'monthly') monthlyTotal += item.base_price + else oneTimeTotal += item.base_price + + for (const mod of item.selected_modules) { + options.push({ + key: `${prodKey}__${slugify(mod.module_name)}`, + label: mod.module_name, + active: true, + billing: item.billing_interval, + price_net: mod.price, + }) + + if (item.billing_interval === 'monthly') monthlyTotal += mod.price + else oneTimeTotal += mod.price + } + } + + return { + customer_ref: customerRef, + generated_at: new Date().toISOString(), + license_key_prefix: `CASP-${new Date().getFullYear()}-`, + options, + monthly_total_net: monthlyTotal, + one_time_total_net: oneTimeTotal, + } +} diff --git a/shop/lib/types.ts b/shop/lib/types.ts index a3be56f..4143655 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -1,59 +1,143 @@ +// ─── Katalog (gelesen aus Supabase) ────────────────────────────────────────── + +export type BillingInterval = 'one_time' | 'monthly' + export type Product = { - id: string; - name: string; - description?: string | null | undefined; - base_price: number; - is_active: boolean; - billing_interval: 'one_time' | 'monthly'; - created_at: string; - updated_at: string; - category_id?: string | null; - category?: Category | null; - modules?: ProductModule[]; -}; + id: string + name: string + description?: string | null + base_price: number + tax_rate: number + billing_interval: BillingInterval + category_id?: string | null + category?: Category | null + modules?: ProductModule[] + created_at: string +} export type Category = { - id: string; - name: string; - description?: string | null; - icon?: string | null; - sort_order: number; - is_required: boolean; - created_at: string; -}; + id: string + name: string + description?: string | null + icon?: string | null + sort_order: number + is_required: boolean + created_at: string +} export type ProductModule = { - id: string; - product_id: string; - name: string; - description?: string | null | undefined; - price: number; - is_required: boolean; - is_active?: boolean; - requirements: string[]; - exclusions: string[]; - created_at: string; -}; + id: string + product_id: string + name: string + description?: string | null + price: number + 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 +} export type Profile = { - id: string; - company_name: string | null; - vat_id: string | null; - first_name: string | null; - last_name: string | null; - address: string | null; - city: string | null; - zip_code: string | null; - country: string; - updated_at: string; -}; + id: string + company_name: string | null + vat_id: string | null + first_name: string | null + last_name: string | null + address: string | null + city: string | null + zip_code: string | null + country: string + email?: string | null + updated_at: string +} + +// ─── Wizard-Zustand (flüchtig, im RAM) ─────────────────────────────────────── + +export type CategorySelection = { + productId: string | null + moduleIds: string[] +} + +/** key = category_id */ +export type WizardSelections = Record + +// ─── Versioned Snapshots (unveränderlich in JSONB gespeichert) ──────────────── + +/** + * Kundendaten zum Zeitpunkt der Bestellung. + * schema_version erlaubt spätere Migrationsfunktionen ohne DB-Änderung. + */ +export type CustomerSnapshot = { + schema_version: 1 + company_name: string + vat_id: string + first_name: string + last_name: string + address: string + zip_code: string + city: string + email?: string +} + +export type OrderModuleSnapshot = { + module_id: string + module_name: string + price: number +} + +export type OrderItem = { + category_id: string + category_name: string + product_id: string + product_name: string + base_price: number + billing_interval: BillingInterval + selected_modules: OrderModuleSnapshot[] + item_total: number +} + +/** + * Vollständiger Bestell-Snapshot – wird in orders.order_data (JSONB) gespeichert. + */ +export type OrderSnapshot = { + schema_version: 1 + billing_cycle: BillingInterval + items: OrderItem[] + subtotal: number + tax_rate: number + tax_amount: number + total: number +} + +// ─── DB-Zeile (orders-Tabelle) ──────────────────────────────────────────────── export type Order = { - id: string; - user_id: string; - status: 'pending' | 'completed' | 'cancelled'; - total_amount: number; - items: any; // Snapshot of product and modules - billing_details: any; - created_at: string; -}; + id: string + user_id: string | null + customer_data: CustomerSnapshot + order_data: OrderSnapshot + total_price: number + pdf_url: string | null + status: 'pending' | 'active' | 'cancelled' + created_at: string +} + +// ─── Lizenz-Output (für Lizenzserver / ERP) ─────────────────────────────────── + +export type LicenseOption = { + /** Stabiler Slug-Key, z. B. "caspos_basic__backoffice" */ + key: string + label: string + active: boolean + billing: BillingInterval + price_net: number +} + +export type LicenseBundle = { + customer_ref: string + generated_at: string + license_key_prefix: string + options: LicenseOption[] + monthly_total_net: number + one_time_total_net: number +} +