Compare commits

...

2 Commits

Author SHA1 Message Date
DanielS
a43d598f54 chore(merge): integrate origin/staging into local staging
All checks were successful
Staging Build / build (push) Successful in 2m57s
Resolved conflict in customer-accordion-list.tsx by keeping
the per-device flattening (DeviceCard) implementation.
2026-07-23 09:47:37 +02:00
DanielS
0e4495292d feat(my-customers): flatten orders into per-device cards
- split orders by device_name into FlattenedDevice entries
- strip device prefixes from product and module names
- render one DeviceCard per Kasse in the customer accordion
- add targeted upgrade URL with orderId, customer_id, device_id
- wizard starts at step 3 in upgrade mode with locked customer
- mark existing licensed modules as read-only with badge
2026-07-23 09:45:14 +02:00
7 changed files with 369 additions and 157 deletions

View File

@@ -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."

View File

@@ -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) {
</div>
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
<OrderDataWrapper orderId={orderId} mode={params.mode} />
<OrderDataWrapper
orderId={orderId}
mode={params.mode}
customerId={params.customer_id}
deviceId={params.device_id}
/>
</Suspense>
</div>
</div>
)
}
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}
/>
)
}

View File

@@ -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<string, string> = {
}
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 (
<div className="p-4 rounded-xl bg-slate-900/60 border border-white/10 hover:border-white/20 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4">
{/* Kassen-Info links */}
<div className="space-y-2 min-w-[200px] flex-1">
{/* Kassen-Name + Status */}
<div className="flex items-center gap-2 flex-wrap">
<Monitor className="w-4 h-4 text-primary shrink-0" />
<span className="font-bold text-white text-base">{device.deviceName}</span>
<Badge
className={`text-xs ${statusClass[device.orderStatus] ?? 'bg-slate-500/20 text-slate-300'}`}
>
{statusLabel[device.orderStatus] ?? device.orderStatus}
</Badge>
</div>
{/* Bestellnummer + Datum */}
<p className="text-xs text-slate-400 font-mono">
#{device.orderNumber} &bull; Erstellt am{' '}
{new Date(device.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</p>
{/* Produkt + Modul Chips */}
{chips.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{chips.map((chip, idx) => (
<span
key={idx}
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
>
{chip}
</span>
))}
</div>
)}
</div>
{/* Preis + Aktions-Buttons rechts */}
<div className="flex items-center gap-3 flex-wrap justify-between md:justify-end border-t md:border-t-0 pt-3 md:pt-0 border-white/5 shrink-0">
{/* Preis */}
<div className="text-left md:text-right mr-2">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Gesamtwert</p>
<p className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(device.totalPrice)}
</p>
</div>
{/* Upgrade / Abo Button */}
<Link href={upgradeUrl}>
<Button
size="sm"
className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 text-xs font-semibold gap-1.5"
>
<Sparkles className="w-3.5 h-3.5" />
Upgrade / Abo
</Button>
</Link>
{/* PDF Download */}
{device.pdfUrl && (
<Button
variant="outline"
size="sm"
asChild
className="border-white/10 hover:bg-white/10 text-xs text-slate-300"
>
<a
href={`/api/orders/${device.orderId}/download`}
download
title="Bestellbestätigung PDF herunterladen"
>
<Download className="w-3.5 h-3.5 mr-1" />
PDF
</a>
</Button>
)}
{/* Details */}
<Link href={`/order/success?id=${device.orderId}`}>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white text-xs p-2"
title="Details anzeigen"
>
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
)
}
// ── Haupt-Komponente ──────────────────────────────────────────────────────────
export function CustomerAccordionList({ customers }: CustomerAccordionListProps) {
const [openCustomerIds, setOpenCustomerIds] = useState<Record<string, boolean>>(() => {
// Default open all customers with orders
const initial: Record<string, boolean> = {}
customers.forEach((c) => {
initial[c.id] = true
@@ -91,7 +214,7 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
return (
<div className="space-y-6">
{/* Suche */}
{/* Suchfeld */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
@@ -103,18 +226,18 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
/>
</div>
{/* Accordion List */}
{/* Akkordeon-Liste */}
<div className="space-y-4">
{filteredCustomers.map((customer) => {
const isOpen = !!openCustomerIds[customer.id]
const orders = customer.orders || []
const devices = customer.devices || []
return (
<Card
key={customer.id}
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
>
{/* Accordion Header */}
{/* Akkordeon-Header */}
<div
onClick={() => 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)
)}
</div>
<p className="text-sm text-slate-400">
{[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}` : ''}
</p>
@@ -141,9 +265,10 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
</div>
<div className="flex items-center gap-3 ml-auto">
{/* Kassen-Anzahl Badge */}
<Badge variant="outline" className="border-white/10 text-slate-300 bg-white/5">
<Monitor className="w-3.5 h-3.5 mr-1 text-primary" />
{orders.length} {orders.length === 1 ? 'Kasse / Bestellung' : 'Kassen / Bestellungen'}
<Package className="w-3.5 h-3.5 mr-1 text-primary" />
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
</Badge>
{!customer.is_anonymized && (
@@ -166,139 +291,37 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
size="sm"
className="text-slate-400 hover:text-white p-1"
>
{isOpen ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
{isOpen ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Accordion Content (Kassen/Orders) */}
{/* Ausgeklappter Bereich mit Kassen */}
{isOpen && (
<div className="border-t border-white/5 bg-slate-950/40 p-5 space-y-3">
{orders.length === 0 ? (
{devices.length === 0 ? (
<div className="text-slate-500 text-sm py-4 text-center">
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
</div>
) : (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
Zugeordnete Kassen & Systeme ({orders.length})
Zugeordnete Kassen ({devices.length})
</p>
{orders.map((order) => {
const items = order.order_data?.items || []
return (
<div
key={order.id}
className="p-4 rounded-xl bg-slate-900/60 border border-white/10 hover:border-white/20 transition-all flex flex-col md:flex-row md:items-center justify-between gap-4"
>
{/* Kassen Info */}
<div className="space-y-1 min-w-[200px]">
<div className="flex items-center gap-2">
<span className="font-bold text-white text-base">
{order.register_name}
</span>
<Badge
className={`text-xs ${
statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'
}`}
>
{statusLabel[order.status] ?? order.status}
</Badge>
</div>
<p className="text-xs text-slate-400 font-mono">
#{order.order_number} Erstellt am{' '}
{new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</p>
{/* Module / Produkte summary */}
{items.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{items.map((item, idx) => (
<span
key={idx}
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
>
<span className="font-semibold text-white mr-1">{item.device_name || 'Kasse'}:</span>
{item.product_name}
{item.selected_modules?.length > 0 && (
<span className="text-slate-500 ml-1">
(+{item.selected_modules.length} {item.selected_modules.length === 1 ? 'Modul' : 'Module'})
</span>
)}
</span>
{devices.map((device, idx) => (
<DeviceCard
key={`${device.orderId}-${device.deviceId}-${idx}`}
device={device}
customerId={customer.id}
/>
))}
</div>
)}
</div>
{/* Preis & Action Buttons */}
<div className="flex items-center gap-3 flex-wrap justify-between md:justify-end border-t md:border-t-0 pt-3 md:pt-0 border-white/5">
<div className="text-left md:text-right mr-2">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
Gesamtwert
</p>
<p className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(order.total_price)}
</p>
</div>
{/* Upgrade / Abo Button (Flow Fall B) */}
<Link
href={`/wizard?mode=extension&orderId=${order.id}`}
>
<Button
size="sm"
className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/30 text-xs font-semibold gap-1.5"
>
<Sparkles className="w-3.5 h-3.5" />
Upgrade / Abo
</Button>
</Link>
{/* PDF Download Button */}
{order.pdf_url && (
<Button
variant="outline"
size="sm"
asChild
className="border-white/10 hover:bg-white/10 text-xs text-slate-300"
>
<a
href={`/api/orders/${order.id}/download`}
download
title="Bestellbestätigung PDF herunterladen"
>
<Download className="w-3.5 h-3.5 mr-1" />
PDF
</a>
</Button>
)}
{/* Details Button */}
<Link href={`/order/success?id=${order.id}`}>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white text-xs p-2"
title="Details anzeigen"
>
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
)
})}
</div>
)}
</div>
)}
</Card>
)

View File

@@ -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<string | null>(
initialOrder?.company_id ?? (isAdmin ? 'all' : null)
@@ -138,7 +145,26 @@ export function OrderWizard({
}
})
})
const [deviceName, setDeviceName] = useState<string>('')
// Upgrade-Mode: Ermittle bereits lizenzierte Modul-IDs für die Ziel-Kasse
const existingModuleIds = useMemo<string[]>(() => {
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<string>(() => {
if (upgradeMode && lockedDeviceId) return `${lockedDeviceId} Upgrade`
return ''
})
const [editingIdx, setEditingIdx] = useState<number | null>(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<EndCustomer[]>(initialEndCustomers)
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
initialOrder
? initialOrder.end_customer_id
: (initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null)
)
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(() => {
// 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('')
@@ -839,6 +866,17 @@ export function OrderWizard({
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
</div>
{/* Upgrade-Modus Hinweis-Banner */}
{upgradeMode && lockedDeviceId && (
<div className="mb-4 p-3 rounded-xl bg-primary/10 border border-primary/20 text-xs text-primary/90 flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
<div>
<p className="font-semibold">Upgrade-Modus: {lockedDeviceId}</p>
<p className="text-primary/70 mt-0.5">Bereits lizenzierte Module sind ausgegraut und können nicht doppelt gebucht werden.</p>
</div>
</div>
)}
{/* Scrollable category content */}
<StepSoftware
visibleCategories={visibleCategories}
@@ -853,6 +891,7 @@ export function OrderWizard({
selectedBillingInterval={selectedBillingInterval}
billingLabel={billingLabel}
billingBadgeClass={billingBadgeClass}
existingModuleIds={existingModuleIds}
/>
</div>

View File

@@ -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 (
<Card className="glass-dark border-white/10">
@@ -215,37 +218,57 @@ export function StepSoftware({
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
{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 (
<div
key={module.id}
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
className={`flex flex-col p-3 rounded-lg border ml-2 transition-colors ${
isExistingLicense
? 'border-primary/20 bg-primary/5 opacity-75'
: disabled
? 'border-white/5 bg-white/5 opacity-50'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-start space-x-3">
<Checkbox
id={`mod-${cat.id}-${module.id}`}
checked={checked}
onCheckedChange={() => toggleModule(cat.id, module.id)}
onCheckedChange={() => !isExistingLicense && toggleModule(cat.id, module.id)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`mod-${cat.id}-${module.id}`}
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
htmlFor={isExistingLicense ? undefined : `mod-${cat.id}-${module.id}`}
className={`font-medium flex justify-between text-white ${
isExistingLicense ? 'cursor-default' : disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<span>{module.name}</span>
<span className="flex items-center gap-1.5">
{module.name}
{isExistingLicense && (
<span className="inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded bg-primary/20 text-primary border border-primary/30 font-semibold">
<Lock className="w-2.5 h-2.5" /> Bereits lizenziert
</span>
)}
</span>
<span className="text-primary font-bold">
+{new Intl.NumberFormat('de-DE', {
{isExistingLicense ? (
<span className="text-slate-500 text-xs">inkl.</span>
) : (
<>+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}
}).format(module.price)}</>
)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400">{module.description}</p>
)}
{disabled && (
{!isExistingLicense && disabled && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
@@ -258,7 +281,7 @@ export function StepSoftware({
</div>
{/* Scalable Quantity */}
{checked && module.has_quantity && (
{checked && !isExistingLicense && module.has_quantity && (
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
<Input

View File

@@ -2,7 +2,7 @@
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName } from '@/lib/types'
import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName, FlattenedDevice, EndCustomerWithDevices, OrderItem } from '@/lib/types'
/**
* Holt alle Bestellanfragen.
@@ -74,6 +74,91 @@ export async function getPartnerCustomersWithOrders(): Promise<EndCustomerWithOr
})
}
/**
* Bereinigt Kassen-Präfixe aus Produkt- und Modulnamen.
* Entfernt Muster wie "Kasse1:", "kass2:", "kasse 1:" etc.
*/
function stripDevicePrefix(name: string): string {
return name.replace(/^kasse?\s*\d*\s*:\s*/i, '').trim()
}
/**
* Holt alle Endkunden mit geflatteteten Kassen (eine Kasse = ein FlattenedDevice).
* Jede Order wird nach device_name in einzelne Kassen aufgespalten.
* RLS filtert automatisch über get_auth_company_id().
*/
export async function getPartnerCustomersWithDevices(): Promise<EndCustomerWithDevices[]> {
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<string, OrderItem[]> = {}
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

View File

@@ -179,6 +179,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 = {