diff --git a/shop/app/admin/wysiwyg/wysiwyg-client.tsx b/shop/app/admin/wysiwyg/wysiwyg-client.tsx index 1a9c3b8..2ff5872 100644 --- a/shop/app/admin/wysiwyg/wysiwyg-client.tsx +++ b/shop/app/admin/wysiwyg/wysiwyg-client.tsx @@ -402,6 +402,30 @@ export function WysiwygAdminClient({ + {/* Verknüpfte monatliche Gebühr */} +
+ Verknüpfte Gebühr: + +
+ {/* Modul hinzufügen Popover */}
(); for (const item of groupItems) { const itemSnapshot = buildOrderSnapshot( @@ -93,8 +94,53 @@ export async function POST(request: Request) { })); orderItemsList.push(...itemsWithDevice); total += itemSnapshot.total; + + // Echte linked fee products ermitteln + if (item.selections) { + for (const catId in item.selections) { + const sel = item.selections[catId]; + + if (sel.productId) { + const p = products.find((prod: any) => prod.id === sel.productId); + if (p && p.linked_fee_product_id) { + feeProductIds.add(p.linked_fee_product_id); + } + } + + sel.productIds?.forEach((pId: string) => { + const p = products.find((prod: any) => prod.id === pId); + if (p && p.linked_fee_product_id) { + feeProductIds.add(p.linked_fee_product_id); + } + }); + } + } } + feeProductIds.forEach(feeProdId => { + const feeProduct = products.find((p: any) => p.id === feeProdId); + if (feeProduct) { + const isIntervalMatch = + (type === 'purchase' && feeProduct.billing_interval === 'one_time') || + (type === 'subscription' && feeProduct.billing_interval === 'monthly'); + + if (isIntervalMatch) { + orderItemsList.push({ + category_id: 'dienste-gebuehren', + category_name: 'Dienste & Gebühren', + product_id: feeProduct.id, + product_name: feeProduct.name, + base_price: Number(feeProduct.base_price), + billing_interval: feeProduct.billing_interval, + selected_modules: [], + item_total: Number(feeProduct.base_price), + device_name: 'Zusatzleistung' + }); + total += Number(feeProduct.base_price); + } + } + }); + const orderSnapshot = { schema_version: 1, billing_cycle: type === 'purchase' ? 'one_time' : 'monthly', diff --git a/shop/app/my-orders/page.tsx b/shop/app/my-orders/page.tsx index 971fe51..cf1aaf2 100644 --- a/shop/app/my-orders/page.tsx +++ b/shop/app/my-orders/page.tsx @@ -126,14 +126,17 @@ export default async function MyOrdersPage() { {/* Produkte kurz */} {items.length > 0 && (
- {items.map((item, i) => ( + {items.map((item, idx) => ( + {item.device_name || 'Kasse'}: {item.product_name} {item.selected_modules.length > 0 && ( - +{item.selected_modules.length} Module + + (+{item.selected_modules.length} {item.selected_modules.length === 1 ? 'Modul' : 'Module'}) + )} ))} diff --git a/shop/app/order/success/page.tsx b/shop/app/order/success/page.tsx index c8693a3..d1be63a 100644 --- a/shop/app/order/success/page.tsx +++ b/shop/app/order/success/page.tsx @@ -118,7 +118,7 @@ export default async function OrderSuccessPage({ {Object.entries(groupedItems).map(([deviceName, devItems]) => (
- Kasse: {deviceName} + {deviceName === 'Zusatzleistung' ? 'Backoffice' : `Kasse: ${deviceName}`}
{devItems.map((item) => (
diff --git a/shop/components/admin/orders-table.tsx b/shop/components/admin/orders-table.tsx index 3c885ee..4ce3b8b 100644 --- a/shop/components/admin/orders-table.tsx +++ b/shop/components/admin/orders-table.tsx @@ -182,17 +182,22 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
-
- {items.map((item: any) => ( - + {items.map((item: any, idx: number) => ( +
- {item.product_name} + + {item.device_name || 'Kasse'}: + + {item.product_name} {item.selected_modules?.length > 0 && ( - +{item.selected_modules.length} + + + {item.selected_modules.map((m: any) => m.module_name || m.name).join(', ')} + )} - +
))}
diff --git a/shop/components/invoice-pdf.tsx b/shop/components/invoice-pdf.tsx index 951121b..49cd9d2 100644 --- a/shop/components/invoice-pdf.tsx +++ b/shop/components/invoice-pdf.tsx @@ -176,7 +176,7 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => { - Kasse: {deviceName} + {deviceName === 'Zusatzleistung' ? 'Backoffice' : `Kasse: ${deviceName}`} {devItems.map((item: any, idx: number) => ( diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 8f9d713..d0491be 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -360,6 +360,8 @@ export function OrderWizard({ return { monthlyTotal: monthly, oneTimeTotal: oneTime } }, [selections, products, categories, moduleQuantities]) + + // Ermittlung der Update-Faktoren basierend auf dem Lizenzdatum (nur für Kauf/einmalig relevant) const updatePriceModifier = useMemo(() => { if (selectedBillingInterval !== 'one_time' || !lastLicenseDate) return { factor: 1 } @@ -413,6 +415,96 @@ export function OrderWizard({ }) }, [visibleCategories, selections, products, selectedBillingInterval]) + const finalItemsToShow = basketItems.length > 0 + ? basketItems + : (allCategoriesFilled && productValidationErrors.length === 0 + ? [{ deviceName: deviceName || 'Kasse 1', selections, moduleQuantities, billingInterval: selectedBillingInterval }] + : []) + + // Gesamtsummen für alle Items in der Bestellung (finalItemsToShow) + const { overallMonthlyTotal, overallOneTimeTotal, linkedFeeProducts } = useMemo(() => { + let monthly = 0 + let oneTime = 0 + const feeProductIds = new Set() + + finalItemsToShow.forEach(item => { + categories.forEach(cat => { + const sel = item.selections[cat.id] + if (!sel) return + + const selectedProds: Product[] = [] + if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) { + sel.productIds.forEach((pId: string) => { + const p = products.find(prod => prod.id === pId) + if (p) selectedProds.push(p) + }) + } else if (sel.productId) { + const p = products.find(prod => prod.id === sel.productId) + if (p) selectedProds.push(p) + } + + const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0 + const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price) + + sortedProds.forEach((prod, idx) => { + const isFree = idx < freeLimit + const basePrice = isFree ? 0 : prod.base_price + + if (prod.billing_interval === 'monthly') { + monthly += basePrice + } else { + oneTime += basePrice + } + + if (prod.linked_fee_product_id) { + feeProductIds.add(prod.linked_fee_product_id) + } + + // Module + sel.moduleIds?.forEach((mId: string) => { + const mod = prod.modules?.find(m => m.id === mId) + if (mod) { + const qty = item.moduleQuantities?.[mId] || 1 + if (prod.billing_interval === 'monthly') { + monthly += mod.price * qty + } else { + oneTime += mod.price * qty + } + } + }) + }) + }) + }) + + const feeProducts: Product[] = [] + feeProductIds.forEach(id => { + const p = products.find(prod => prod.id === id) + if (p) { + feeProducts.push(p) + if (p.billing_interval === 'monthly') { + monthly += p.base_price + } else { + oneTime += p.base_price + } + } + }) + + return { + overallMonthlyTotal: monthly, + overallOneTimeTotal: oneTime, + linkedFeeProducts: feeProducts + } + }, [finalItemsToShow, products, categories]) + + // Gesamte Endbeträge ermitteln + const overallOneTimeNet = overallOneTimeTotal * discountFactor + const overallOneTimeTax = overallOneTimeNet * 0.19 + const overallOneTimeGross = overallOneTimeNet + overallOneTimeTax + + const overallMonthlyNet = overallMonthlyTotal + const overallMonthlyTax = overallMonthlyNet * 0.19 + const overallMonthlyGross = overallMonthlyNet + overallMonthlyTax + const isNextStepDisabled = !allCategoriesFilled || productValidationErrors.length > 0 // Helper: toggle module for a category's selected product @@ -614,11 +706,7 @@ export function OrderWizard({ } } - const finalItemsToShow = basketItems.length > 0 - ? basketItems - : (allCategoriesFilled && productValidationErrors.length === 0 - ? [{ deviceName: deviceName || 'Kasse 1', selections, moduleQuantities, billingInterval: selectedBillingInterval }] - : []) + function selectProduct(catId: string, productId: string) { setSelections(prev => { @@ -856,14 +944,14 @@ export function OrderWizard({ finalItemsToShow={finalItemsToShow} visibleCategories={visibleCategories} products={products} - oneTimeTotal={oneTimeTotal} - monthlyTotal={monthlyTotal} - oneTimeNet={oneTimeNet} - oneTimeTax={oneTimeTax} - oneTimeGross={oneTimeGross} - monthlyNet={monthlyNet} - monthlyTax={monthlyTax} - monthlyGross={monthlyGross} + oneTimeTotal={overallOneTimeTotal} + monthlyTotal={overallMonthlyTotal} + oneTimeNet={overallOneTimeNet} + oneTimeTax={overallOneTimeTax} + oneTimeGross={overallOneTimeGross} + monthlyNet={overallMonthlyNet} + monthlyTax={overallMonthlyTax} + monthlyGross={overallMonthlyGross} updatePriceModifier={updatePriceModifier} selectedEndCustomer={selectedEndCustomer} customerData={customerData} @@ -872,6 +960,7 @@ export function OrderWizard({ isSubmitting={isSubmitting} initialOrder={initialOrder} prevStep={prevStep} + linkedFeeProducts={linkedFeeProducts} /> )} diff --git a/shop/components/wizard/license-lookup-panel.tsx b/shop/components/wizard/license-lookup-panel.tsx index a75178d..5547700 100644 --- a/shop/components/wizard/license-lookup-panel.tsx +++ b/shop/components/wizard/license-lookup-panel.tsx @@ -242,7 +242,7 @@ export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProp setNotFound(false) }} onKeyDown={handleKeyDown} - placeholder="z. B. 995502-00" + placeholder="z. B. 995500-00" className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10" /> @@ -287,7 +287,7 @@ export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProp className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors" onClick={() => setLicenseKey('995501-00')} > - 995500-00 + 995501-00 (abgelaufen)

diff --git a/shop/components/wizard/step-customer.tsx b/shop/components/wizard/step-customer.tsx index 396dd75..22b0c9a 100644 --- a/shop/components/wizard/step-customer.tsx +++ b/shop/components/wizard/step-customer.tsx @@ -177,7 +177,7 @@ export function StepCustomer({
setPartnerSearchTerm(e.target.value)} className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-sm h-9" @@ -202,7 +202,10 @@ export function StepCustomer({
{/* Reserved Fixed Card: Alle Partner */}
setSelectedCompanyId('all')} + onClick={() => { + setSelectedCompanyId('all') + setSelectedEndCustomerId(null) + }} className={`flex items-center justify-between p-3.5 rounded-xl border-2 cursor-pointer transition-all select-none ${ selectedCompanyId === 'all' || !selectedCompanyId ? 'border-primary bg-primary/10 shadow-lg shadow-primary/5' @@ -240,7 +243,10 @@ export function StepCustomer({ return (
setSelectedCompanyId(c.id)} + onClick={() => { + setSelectedCompanyId(c.id) + setSelectedEndCustomerId(null) + }} className={`flex items-center justify-between p-3.5 rounded-xl border-2 cursor-pointer transition-all select-none ${ isSelected ? 'border-primary bg-primary/10 shadow-lg shadow-primary/5' @@ -253,7 +259,11 @@ export function StepCustomer({

{c.name}

-

ID: {c.id}

+ {(c.street || c.zip || c.city) && ( +

+ {[c.street, [c.zip, c.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')} +

+ )}
@@ -592,9 +602,7 @@ export function StepCustomer({ onClick={nextStep} disabled={ customerMode === 'create' || - (customerMode === 'select' && - customersByCompany.length > 0 && - !selectedEndCustomerId) + !selectedEndCustomerId } > Weiter zum Abrechnungsmodell diff --git a/shop/components/wizard/step-summary.tsx b/shop/components/wizard/step-summary.tsx index b73eeee..f28aa22 100644 --- a/shop/components/wizard/step-summary.tsx +++ b/shop/components/wizard/step-summary.tsx @@ -28,6 +28,7 @@ interface StepSummaryProps { isSubmitting: boolean initialOrder: any prevStep: () => void + linkedFeeProducts?: Product[] } function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) { @@ -56,6 +57,7 @@ export function StepSummary({ isSubmitting, initialOrder, prevStep, + linkedFeeProducts = [], }: StepSummaryProps) { return ( @@ -142,6 +144,24 @@ export function StepSummary({ })}
))} + {linkedFeeProducts && linkedFeeProducts.length > 0 && ( +
+
+ Backoffice +
+ {linkedFeeProducts.map(p => ( +
+
+ {p.name} + + {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.base_price)} + {' '}{p.billing_interval === 'monthly' ? '/ mtl.' : 'einmalig'} + +
+
+ ))} +
+ )}
{selectedEndCustomer ? ( diff --git a/shop/lib/types.ts b/shop/lib/types.ts index b47ea04..8d431f9 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -18,6 +18,7 @@ export type Product = { requirements?: string[] exclusions?: string[] allow_update_discount?: boolean + linked_fee_product_id?: string | null } export type Category = { diff --git a/shop/supabase/migrations/20260722100000_add_linked_fee_to_modules.sql b/shop/supabase/migrations/20260722100000_add_linked_fee_to_modules.sql new file mode 100644 index 0000000..331de7d --- /dev/null +++ b/shop/supabase/migrations/20260722100000_add_linked_fee_to_modules.sql @@ -0,0 +1,3 @@ +-- Add linked_fee_product_id to product_modules table +ALTER TABLE public.product_modules +ADD COLUMN IF NOT EXISTS linked_fee_product_id UUID REFERENCES public.products(id) ON DELETE SET NULL; diff --git a/shop/supabase/migrations/20260722110000_move_linked_fee_to_products.sql b/shop/supabase/migrations/20260722110000_move_linked_fee_to_products.sql new file mode 100644 index 0000000..212a29a --- /dev/null +++ b/shop/supabase/migrations/20260722110000_move_linked_fee_to_products.sql @@ -0,0 +1,7 @@ +-- Remove linked_fee_product_id from product_modules +ALTER TABLE public.product_modules +DROP COLUMN IF EXISTS linked_fee_product_id; + +-- Add linked_fee_product_id to products +ALTER TABLE public.products +ADD COLUMN IF NOT EXISTS linked_fee_product_id UUID REFERENCES public.products(id) ON DELETE SET NULL; diff --git a/shop/supabase/seed.sql b/shop/supabase/seed.sql index 5637f91..42519f9 100644 --- a/shop/supabase/seed.sql +++ b/shop/supabase/seed.sql @@ -1,13 +1,16 @@ -- Seed Products -INSERT INTO public.products (id, name, description, base_price, tax_rate) +INSERT INTO public.products (id, name, description, base_price, tax_rate, billing_interval, show_in_abo, show_in_kauf) VALUES -('d1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'CASPOS Cloud', 'Die modulare Cloud-Lösung für Ihren Einzelhandel.', 49.00, 19.00), -('d2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00); +('d1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'CASPOS Cloud', 'Die modulare Cloud-Lösung für Ihren Einzelhandel.', 49.00, 19.00, 'monthly', true, true), +('d2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'CASPOS Gastro', 'Spezialisiert auf Gastronomie mit Tischplan und Funkbonieren.', 79.00, 19.00, 'monthly', true, true), +('prod-poscloud-fee', 'POS Cloud Grundgebühr', 'Monatliche Grundgebühr für die POS Cloud Nutzung.', 19.00, 19.00, 'monthly', false, false); -- Seed Modules for CASPOS Cloud INSERT INTO public.product_modules (id, product_id, name, description, price, requirements, exclusions) VALUES ('m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Bestandsführung Pro', 'Erweiterte Lagerverwaltung.', 15.00, '{}', '{}'), -('m2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'), -('m3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'), -('m4a4a4a4-a4a4-a4a4-a4a4-a4a4a4a4a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "m3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3"}'); +('m2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'DATEV Export', 'Direkte Schnittstelle zum Steuerberater.', 10.00, '{}', '{}'), +('m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Filialverwaltung', 'Zentrale Steuerung mehrerer Standorte.', 25.00, '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1"}', '{}'), +('m4a4a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4-a4a4', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Small Business Modus', 'Reduzierter Funktionsumfang für Kleinunternehmer.', 0.00, '{}', '{"m1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1", "m3a3a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3-a3a3"}'), +('m-poscloud-cloud', 'd1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}'), +('m-poscloud-gastro', 'd2a2a2a2-a2a2-a2a2-a2a2-a2a2-a2a2-a2a2a2a2', 'Schnittstelle POS Cloud', 'Anbindung an die POS Cloud.', 0.00, '{}', '{}');