diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx index 55c6692..2b4dc21 100644 --- a/shop/components/admin/create-product-dialog.tsx +++ b/shop/components/admin/create-product-dialog.tsx @@ -55,6 +55,8 @@ const productSchema = z.object({ base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'), billing_interval: z.enum(['one_time', 'monthly']).default('monthly'), + show_in_kauf: z.boolean().default(true), + show_in_abo: z.boolean().default(true), modules: z.array(moduleSchema).default([]), }) @@ -72,6 +74,8 @@ export function CreateProductDialog({ children, categories, product }: { childre base_price: product?.base_price || 0, category_id: product?.category_id || '', billing_interval: product?.billing_interval || 'monthly', + show_in_kauf: product ? (product.show_in_kauf ?? true) : true, + show_in_abo: product ? (product.show_in_abo ?? true) : true, modules: product?.modules?.map(m => ({ id: m.id, name: m.name, @@ -217,6 +221,45 @@ export function CreateProductDialog({ children, categories, product }: { childre )} /> +
+ ( + + + + +
+ In Kauf-Auswahl anzeigen + Produkt im Kauf-Zweig anzeigen +
+
+ )} + /> + ( + + + + +
+ In Abo-Auswahl anzeigen + Produkt im Abo-Zweig anzeigen +
+
+ )} + /> +
+ ('monthly') + // Per-category selection map: categoryId -> { productId, moduleIds } const [selections, setSelections] = useState>(() => { const init: Record = {} categories.forEach(cat => { - const first = products.find(p => p.category_id === cat.id) + const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false) init[cat.id] = { productId: first?.id ?? null, moduleIds: [] } }) return init }) - // Only REQUIRED categories must have a product selected - const allCategoriesFilled = useMemo( - () => categories.filter(cat => cat.is_required).every(cat => !!selections[cat.id]?.productId), - [categories, selections] - ) + // Only REQUIRED categories must have a product selected (which must be filtered by selected branch) + const allCategoriesFilled = useMemo(() => { + return categories + .filter(cat => cat.is_required) + .every(cat => { + const sel = selections[cat.id] + if (!sel?.productId) return false + const prod = products.find(p => p.id === sel.productId) + if (!prod) return false + // Ensure the selected product is actually allowed in the current branch + return selectedBillingInterval === 'one_time' + ? prod.show_in_kauf !== false + : prod.show_in_abo !== false + }) + }, [categories, selections, products, selectedBillingInterval]) - // Total price across all selections // Total price calculations (split by billing interval) const { monthlyTotal, oneTimeTotal } = useMemo(() => { let monthly = 0 @@ -171,6 +183,20 @@ export function OrderWizard({ const nextStep = () => setStep(s => s + 1) const prevStep = () => setStep(s => s - 1) + // Abrechnungsmodell wechseln (und Selektionen der Kategorien anpassen) + const handleBillingIntervalChange = (val: 'one_time' | 'monthly') => { + setSelectedBillingInterval(val) + const newSelections: Record = {} + categories.forEach(cat => { + const matchingProd = products.find(p => + p.category_id === cat.id && + (val === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false) + ) + newSelections[cat.id] = { productId: matchingProd?.id ?? null, moduleIds: [] } + }) + setSelections(newSelections) + } + // Neuen Endkunden inline anlegen const handleCreateCustomer = async () => { if (!newCustomerForm.company_name.trim()) return @@ -199,6 +225,7 @@ export function OrderWizard({ customerProfile: customerData, endCustomerId: selectedEndCustomerId, endCustomer: selectedEndCustomer, + billingInterval: selectedBillingInterval, }) router.push(`/order/success?id=${order.id}`) } catch (error) { @@ -212,24 +239,30 @@ export function OrderWizard({ return (
{/* Progress Stepper */} -
-
- {[1, 2, 3].map(s => ( -
= s - ? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]' - : 'bg-slate-800 text-slate-400' - }`} - > - {step > s ? : s} -
- ))} +
+
+
+ {[1, 2, 3, 4].map(s => ( +
+
= s + ? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]' + : 'bg-slate-800 text-slate-400' + }`} + > + {step > s ? : s} +
+ = s ? 'text-primary' : 'text-slate-500'}`}> + {s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'} + +
+ ))} +
- {/* ───── Step 1: Product Selection per Category ───── */} + {/* ───── Schritt 1: Endkunde wählen / anlegen ───── */} {step === 1 && ( + + + + + Für wen bestellen Sie? + + + Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an. + + + + {/* Modus-Toggle */} +
+ + +
+ + {/* Modus A: Bestandskunde wählen */} + {customerMode === 'select' && ( +
+ {endCustomers.length === 0 ? ( +
+ +

Noch keine Endkunden angelegt.

+ +
+ ) : ( +
+ {endCustomers.filter(c => !c.is_anonymized).map(customer => ( + + ))} +
+ )} +
+ )} + + {/* Modus B: Neuen Kunden anlegen */} + {customerMode === 'create' && ( +
+ {([ + { 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: '' }, + ] as const).map(({ label, key, span, placeholder }) => ( +
+ + setNewCustomerForm(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={placeholder} + className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" + /> +
+ ))} +
+ + +
+
+ )} +
+ + + +
+
+ )} + + {/* ───── Schritt 2: Abrechnungsmodell wählen ───── */} + {step === 2 && ( + + + + + + Abrechnungsmodell wählen + + + Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten? + + + +
+ {/* Option 1: Kauf */} +
handleBillingIntervalChange('one_time')} + className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${ + selectedBillingInterval === 'one_time' + ? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]' + : 'border-white/5 bg-white/5 hover:border-white/20' + }`} + > +
+
+
+ +
+

Einmaliger Kauf

+
+

+ Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren. +

+
+ {selectedBillingInterval === 'one_time' && ( +
+ +
+ )} +
+ + {/* Option 2: Abo */} +
handleBillingIntervalChange('monthly')} + className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${ + selectedBillingInterval === 'monthly' + ? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]' + : 'border-white/5 bg-white/5 hover:border-white/20' + }`} + > +
+
+
+ +
+

Monatliches Abo (Miete)

+
+

+ Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit. +

+
+ {selectedBillingInterval === 'monthly' && ( +
+ +
+ )} +
+
+
+ + + + +
+
+ )} + + {/* ───── Schritt 3: Software wählen ───── */} + {step === 3 && ( +
{/* Left: Category sections */}
- + Software wählen @@ -253,7 +513,13 @@ export function OrderWizard({ {categories.map((cat, idx) => { - const catProducts = products.filter(p => p.category_id === cat.id) + // Filter products based on display flags show_in_kauf / show_in_abo + const catProducts = products.filter(p => { + if (p.category_id !== cat.id) return false + return selectedBillingInterval === 'one_time' + ? p.show_in_kauf !== false + : p.show_in_abo !== false + }) const sel = selections[cat.id] const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null @@ -289,7 +555,7 @@ export function OrderWizard({ {catProducts.length === 0 ? (

- Keine Produkte in dieser Kategorie vorhanden. + Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.

) : ( )}
- + +
@@ -494,151 +763,10 @@ export function OrderWizard({ )} - {/* ───── Step 2: Endkunde wählen / anlegen ───── */} - {step === 2 && ( + {/* ───── Schritt 4: Prüfung & Abschluss ───── */} + {step === 4 && ( - - - - - Für wen bestellen Sie? - - - Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an. - - - - {/* Modus-Toggle */} -
- - -
- - {/* Modus A: Bestandskunde wählen */} - {customerMode === 'select' && ( -
- {endCustomers.length === 0 ? ( -
- -

Noch keine Endkunden angelegt.

- -
- ) : ( -
- {endCustomers.filter(c => !c.is_anonymized).map(customer => ( - - ))} -
- )} -
- )} - - {/* Modus B: Neuen Kunden anlegen */} - {customerMode === 'create' && ( -
- {([ - { 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: '' }, - ] as const).map(({ label, key, span, placeholder }) => ( -
- - setNewCustomerForm(prev => ({ ...prev, [key]: e.target.value }))} - placeholder={placeholder} - className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" - /> -
- ))} -
- - -
-
- )} -
- - - - -
-
- )} - - {/* ───── Step 3: Review & Submit ───── */} - {step === 3 && ( - - diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index 0187080..a23662e 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -55,8 +55,9 @@ export async function submitOrder(params: { customerProfile: Partial endCustomerId?: string | null endCustomer?: EndCustomer | null + billingInterval?: 'one_time' | 'monthly' }): Promise { - const { selections, customerProfile, endCustomerId, endCustomer } = params + const { selections, customerProfile, endCustomerId, endCustomer, billingInterval } = params const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -105,7 +106,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) + const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval) // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert const orderHash = hashOrderSnapshot(orderSnapshot) diff --git a/shop/lib/license-transform.ts b/shop/lib/license-transform.ts index 3205246..e96905f 100644 --- a/shop/lib/license-transform.ts +++ b/shop/lib/license-transform.ts @@ -84,7 +84,8 @@ export function buildCustomerSnapshot( export function buildOrderSnapshot( selections: WizardSelections, products: Product[], - categories: Category[] + categories: Category[], + billingInterval?: 'one_time' | 'monthly' ): OrderSnapshot { const items: OrderItem[] = [] let subtotal = 0 @@ -127,10 +128,10 @@ export function buildOrderSnapshot( 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') + // Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln + const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time') ? 'one_time' - : 'monthly' + : 'monthly') return { schema_version: 1, diff --git a/shop/lib/types.ts b/shop/lib/types.ts index a36499a..ac078a9 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -13,6 +13,8 @@ export type Product = { category?: Category | null modules?: ProductModule[] created_at: string + show_in_kauf?: boolean + show_in_abo?: boolean } export type Category = { diff --git a/shop/supabase/migrations/20260625085800_add_show_in_branches_to_products.sql b/shop/supabase/migrations/20260625085800_add_show_in_branches_to_products.sql new file mode 100644 index 0000000..62744fe --- /dev/null +++ b/shop/supabase/migrations/20260625085800_add_show_in_branches_to_products.sql @@ -0,0 +1,3 @@ +-- Add show_in_kauf and show_in_abo columns to products table +ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_kauf BOOLEAN DEFAULT true; +ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_abo BOOLEAN DEFAULT true;