diff --git a/shop/app/my-customers/page.tsx b/shop/app/my-customers/page.tsx index 04110a5..b0cbdfe 100644 --- a/shop/app/my-customers/page.tsx +++ b/shop/app/my-customers/page.tsx @@ -1,8 +1,8 @@ export const dynamic = 'force-dynamic'; import { redirect } from 'next/navigation' import Link from 'next/link' -import { getPartnerCustomersWithOrders } from '@/lib/actions/queries' -import type { EndCustomerWithOrders } from '@/lib/types' +import { getPartnerCustomersWithDevices } from '@/lib/actions/queries' +import type { EndCustomerWithDevices } from '@/lib/types' import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import { createClient } from '@/lib/supabase/server' @@ -13,10 +13,10 @@ export default async function MyCustomersPage() { const { data: { user } } = await supabase.auth.getUser() if (!user) redirect('/auth/login') - let customers: EndCustomerWithOrders[] = [] + let customers: EndCustomerWithDevices[] = [] let fetchError: string | null = null try { - customers = await getPartnerCustomersWithOrders() + customers = await getPartnerCustomersWithDevices() } catch (err: any) { console.error("Error loading partner customers with orders:", err) fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen." diff --git a/shop/app/order/page.tsx b/shop/app/order/page.tsx index 53bec42..f001a49 100644 --- a/shop/app/order/page.tsx +++ b/shop/app/order/page.tsx @@ -8,7 +8,7 @@ import { redirect } from 'next/navigation' import { Suspense } from 'react' interface PageProps { - searchParams: Promise<{ id?: string; orderId?: string; mode?: string }> + searchParams: Promise<{ id?: string; orderId?: string; mode?: string; customer_id?: string; device_id?: string }> } export default async function OrderPage({ searchParams }: PageProps) { @@ -27,14 +27,29 @@ export default async function OrderPage({ searchParams }: PageProps) { }> - + ) } -async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: string }) { +async function OrderDataWrapper({ + orderId, + mode, + customerId, + deviceId, +}: { + orderId?: string + mode?: string + customerId?: string + deviceId?: string +}) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -84,7 +99,7 @@ async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: st redirect('/order') } - if (orderData.status === 'completed' && !isAdmin && mode !== 'extension') { + if (orderData.status === 'completed' && !isAdmin && mode !== 'extension' && mode !== 'upgrade') { redirect('/order') } @@ -139,6 +154,9 @@ async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: st initialOrder={initialOrder} isAdmin={userData?.role === 'admin'} companies={companies} + upgradeMode={mode === 'upgrade'} + initialEndCustomerId={customerId || null} + lockedDeviceId={deviceId ? decodeURIComponent(deviceId) : null} /> ) } diff --git a/shop/components/customer-accordion-list.tsx b/shop/components/customer-accordion-list.tsx index 52c5964..ec52468 100644 --- a/shop/components/customer-accordion-list.tsx +++ b/shop/components/customer-accordion-list.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import Link from 'next/link' -import type { EndCustomerWithOrders } from '@/lib/types' +import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types' import { ChevronDown, ChevronUp, @@ -15,6 +15,7 @@ import { Monitor, AlertTriangle, Search, + Package, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -40,12 +41,134 @@ const statusClass: Record = { } interface CustomerAccordionListProps { - customers: EndCustomerWithOrders[] + customers: EndCustomerWithDevices[] } +// ── Einzelne Kassen-Karte ───────────────────────────────────────────────────── +function DeviceCard({ + device, + customerId, +}: { + device: FlattenedDevice + customerId: string +}) { + // Alle Produkt-Chips + Modul-Chips aus den Items + const chips: string[] = [] + for (const item of device.items) { + chips.push(item.product_name) + for (const mod of item.selected_modules || []) { + chips.push(mod.module_name) + } + } + + const upgradeUrl = + `/order?mode=upgrade` + + `&orderId=${device.orderId}` + + `&customer_id=${customerId}` + + `&device_id=${encodeURIComponent(device.deviceId)}` + + return ( +
+ {/* Kassen-Info links */} +
+ {/* Kassen-Name + Status */} +
+ + {device.deviceName} + + {statusLabel[device.orderStatus] ?? device.orderStatus} + +
+ + {/* Bestellnummer + Datum */} +

+ #{device.orderNumber} • Erstellt am{' '} + {new Date(device.createdAt).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + })} +

+ + {/* Produkt + Modul Chips */} + {chips.length > 0 && ( +
+ {chips.map((chip, idx) => ( + + {chip} + + ))} +
+ )} +
+ + {/* Preis + Aktions-Buttons rechts */} +
+ {/* Preis */} +
+

Gesamtwert

+

+ {new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + }).format(device.totalPrice)} +

+
+ + {/* Upgrade / Abo Button */} + + + + + {/* PDF Download */} + {device.pdfUrl && ( + + )} + + {/* Details */} + + + +
+
+ ) +} + +// ── Haupt-Komponente ────────────────────────────────────────────────────────── export function CustomerAccordionList({ customers }: CustomerAccordionListProps) { const [openCustomerIds, setOpenCustomerIds] = useState>(() => { - // Default open all customers with orders const initial: Record = {} customers.forEach((c) => { initial[c.id] = true @@ -91,7 +214,7 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps) return (
- {/* Suche */} + {/* Suchfeld */}
- {/* Accordion List */} + {/* Akkordeon-Liste */}
{filteredCustomers.map((customer) => { const isOpen = !!openCustomerIds[customer.id] - const orders = customer.orders || [] + const devices = customer.devices || [] return ( - {/* Accordion Header */} + {/* Akkordeon-Header */}
toggleCustomer(customer.id)} className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap" @@ -133,7 +256,8 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps) )}

- {[customer.first_name, customer.last_name].filter(Boolean).join(' ') || 'Kein Ansprechpartner'} + {[customer.first_name, customer.last_name].filter(Boolean).join(' ') || + 'Kein Ansprechpartner'} {customer.city ? ` • ${customer.city}` : ''} {customer.email ? ` • ${customer.email}` : ''}

@@ -141,9 +265,10 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
+ {/* Kassen-Anzahl Badge */} - - {orders.length} {orders.length === 1 ? 'Kasse / Bestellung' : 'Kassen / Bestellungen'} + + {devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'} {!customer.is_anonymized && ( @@ -166,135 +291,34 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps) size="sm" className="text-slate-400 hover:text-white p-1" > - {isOpen ? : } + {isOpen ? ( + + ) : ( + + )}
- {/* Accordion Content (Kassen/Orders) */} + {/* Ausgeklappter Bereich mit Kassen */} {isOpen && (
- {orders.length === 0 ? ( + {devices.length === 0 ? (
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
) : (

- Zugeordnete Kassen & Systeme ({orders.length}) + Zugeordnete Kassen ({devices.length})

- {orders.map((order) => { - const items = order.order_data?.items || [] - - return ( -
- {/* Kassen Info */} -
-
- - {order.register_name} - - - {statusLabel[order.status] ?? order.status} - -
-

- #{order.order_number} • Erstellt am{' '} - {new Date(order.created_at).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - })} -

- - {/* Module / Produkte summary */} - {items.length > 0 && ( -
- {items.map((item, idx) => ( - - {item.product_name} - {item.selected_modules?.length > 0 && ( - - (+{item.selected_modules.length} Module) - - )} - - ))} -
- )} -
- - {/* Preis & Action Buttons */} -
-
-

- Gesamtwert -

-

- {new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: 'EUR', - }).format(order.total_price)} -

-
- - {/* Upgrade / Abo Button (Flow Fall B) */} - - - - - {/* PDF Download Button */} - {order.pdf_url && ( - - )} - - {/* Details Button */} - - - -
-
- ) - })} + {devices.map((device, idx) => ( + + ))}
)}
diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 2b0c9ca..8f9d713 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -74,6 +74,9 @@ export function OrderWizard({ initialOrder, isAdmin = false, companies = [], + upgradeMode = false, + initialEndCustomerId = null, + lockedDeviceId = null, }: { products: Product[] categories: Category[] @@ -82,9 +85,13 @@ export function OrderWizard({ initialOrder?: Order | null isAdmin?: boolean companies?: any[] + upgradeMode?: boolean + initialEndCustomerId?: string | null + lockedDeviceId?: string | null }) { const router = useRouter() - const [step, setStep] = useState(initialOrder ? 3 : 1) + // Upgrade-Mode: starte direkt bei Schritt 3 (Software) + const [step, setStep] = useState(upgradeMode ? 3 : (initialOrder ? 3 : 1)) const [isSubmitting, setIsSubmitting] = useState(false) const [selectedCompanyId, setSelectedCompanyId] = useState( initialOrder?.company_id ?? (isAdmin ? 'all' : null) @@ -138,7 +145,26 @@ export function OrderWizard({ } }) }) - const [deviceName, setDeviceName] = useState('') + // Upgrade-Mode: Ermittle bereits lizenzierte Modul-IDs für die Ziel-Kasse + const existingModuleIds = useMemo(() => { + if (!upgradeMode || !initialOrder || !lockedDeviceId) return [] + const items = initialOrder.order_data?.items || [] + const deviceItems = items.filter( + (item: any) => (item.device_name || 'Kasse 1') === lockedDeviceId + ) + const moduleIds: string[] = [] + deviceItems.forEach((item: any) => { + item.selected_modules?.forEach((mod: any) => { + if (mod.module_id) moduleIds.push(mod.module_id) + }) + }) + return moduleIds + }, [upgradeMode, initialOrder, lockedDeviceId]) + + const [deviceName, setDeviceName] = useState(() => { + if (upgradeMode && lockedDeviceId) return `${lockedDeviceId} – Upgrade` + return '' + }) const [editingIdx, setEditingIdx] = useState(null) const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' } | null>(null) @@ -154,11 +180,12 @@ export function OrderWizard({ // Endkunden-State const [endCustomers, setEndCustomers] = useState(initialEndCustomers) - const [selectedEndCustomerId, setSelectedEndCustomerId] = useState( - initialOrder - ? initialOrder.end_customer_id - : (initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null) - ) + const [selectedEndCustomerId, setSelectedEndCustomerId] = useState(() => { + // Upgrade-Mode: Kundenauswahl aus URL-Param + if (upgradeMode && initialEndCustomerId) return initialEndCustomerId + if (initialOrder) return initialOrder.end_customer_id + return initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null + }) const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null const [searchTerm, setSearchTerm] = useState('') @@ -751,6 +778,17 @@ export function OrderWizard({ + {/* Upgrade-Modus Hinweis-Banner */} + {upgradeMode && lockedDeviceId && ( +
+ +
+

Upgrade-Modus: {lockedDeviceId}

+

Bereits lizenzierte Module sind ausgegraut und können nicht doppelt gebucht werden.

+
+
+ )} + {/* Scrollable category content */} diff --git a/shop/components/wizard/step-software.tsx b/shop/components/wizard/step-software.tsx index ad8de5f..e1fb093 100644 --- a/shop/components/wizard/step-software.tsx +++ b/shop/components/wizard/step-software.tsx @@ -8,7 +8,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' -import { ShoppingCart, Check, AlertCircle } from 'lucide-react' +import { ShoppingCart, Check, AlertCircle, Lock } from 'lucide-react' import * as Icons from 'lucide-react' import { Category, Product, CategorySelection } from '@/lib/types' @@ -25,6 +25,8 @@ interface StepSoftwareProps { selectedBillingInterval: 'one_time' | 'monthly' billingLabel: (interval: string) => string billingBadgeClass: (interval: string) => string + /** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */ + existingModuleIds?: string[] } function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) { @@ -46,6 +48,7 @@ export function StepSoftware({ selectedBillingInterval, billingLabel, billingBadgeClass, + existingModuleIds = [], }: StepSoftwareProps) { return ( @@ -215,37 +218,57 @@ export function StepSoftware({

Zusatzmodule:

{selectedProduct.modules.map(module => { - const disabled = isModuleDisabled(module, sel?.moduleIds ?? []) - const checked = sel?.moduleIds.includes(module.id) ?? false + const isExistingLicense = existingModuleIds.includes(module.id) + const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? []) + const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false) return (
toggleModule(cat.id, module.id)} + onCheckedChange={() => !isExistingLicense && toggleModule(cat.id, module.id)} disabled={disabled} />
{module.description && (

{module.description}

)} - {disabled && ( + {!isExistingLicense && disabled && (

{module.requirements?.length && !module.requirements.some( reqId => sel?.moduleIds.includes(reqId) @@ -258,7 +281,7 @@ export function StepSoftware({

{/* Scalable Quantity */} - {checked && module.has_quantity && ( + {checked && !isExistingLicense && module.has_quantity && (
{ + const supabase = await createClient() + + const { data, error } = await supabase + .from('end_customers') + .select('*, orders(*)') + .order('company_name', { ascending: true }) + + if (error) throw error + + return (data || []).map((customer: any) => { + const rawOrders = customer.orders || [] + const sortedOrders = [...rawOrders].sort( + (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + + const devices: FlattenedDevice[] = [] + + for (const order of sortedOrders) { + const allItems: OrderItem[] = order.order_data?.items || [] + const totalOrderPrice: number = order.total_price || 0 + + // Gruppiere Items nach device_name + const deviceGroups: Record = {} + for (const item of allItems) { + const key = item.device_name || 'Kasse 1' + if (!deviceGroups[key]) deviceGroups[key] = [] + deviceGroups[key].push(item) + } + + const deviceKeys = Object.keys(deviceGroups) + + for (const deviceKey of deviceKeys) { + const devItems = deviceGroups[deviceKey] + + // Bereinige Produkt- und Modulnamen + const cleanedItems: OrderItem[] = devItems.map(item => ({ + ...item, + product_name: stripDevicePrefix(item.product_name), + selected_modules: (item.selected_modules || []).map(mod => ({ + ...mod, + module_name: stripDevicePrefix(mod.module_name), + })), + })) + + // Anteiliger Preis dieser Kasse (gleichmäßige Aufteilung) + const devicePrice = deviceKeys.length > 1 + ? totalOrderPrice / deviceKeys.length + : totalOrderPrice + + devices.push({ + orderId: order.id, + orderNumber: order.order_number, + orderStatus: order.status, + pdfUrl: order.pdf_url, + createdAt: order.created_at, + deviceId: deviceKey, + deviceName: deviceKey, + items: cleanedItems, + totalPrice: Math.round(devicePrice * 100) / 100, + }) + } + } + + const { orders: _orders, ...customerWithoutOrders } = customer + return { + ...customerWithoutOrders, + devices, + } as EndCustomerWithDevices + }) +} + export interface GetCustomersForWizardParams { partnerCompanyId?: string page?: number diff --git a/shop/lib/types.ts b/shop/lib/types.ts index 8d5fe5c..b47ea04 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -178,6 +178,30 @@ export type EndCustomerWithOrders = EndCustomer & { orders: OrderWithRegisterName[] } +/** + * Eine einzelne Kasse, herausgelöst aus einem Order-Snapshot. + * Ermöglicht die Darstellung einer Kasse = eine Karte (Flattening). + */ +export type FlattenedDevice = { + orderId: string + orderNumber: string + orderStatus: Order['status'] + pdfUrl: string | null + createdAt: string + /** Eindeutiger Bezeichner der Kasse innerhalb der Order (= device_name) */ + deviceId: string + /** Anzeigename der Kasse, bereinigt von Präfixen */ + deviceName: string + /** Items (Produkte + Module) die zu dieser Kasse gehören */ + items: OrderItem[] + /** Anteiliger Gesamtpreis dieser Kasse */ + totalPrice: number +} + +export type EndCustomerWithDevices = EndCustomer & { + devices: FlattenedDevice[] +} + // ─── Lizenz-Output (für Lizenzserver / ERP) ─────────────────────────────────── export type LicenseOption = {