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
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} • 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,135 +291,34 @@ 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"
|
||||
>
|
||||
{item.product_name}
|
||||
{item.selected_modules?.length > 0 && (
|
||||
<span className="text-slate-500 ml-1">
|
||||
(+{item.selected_modules.length} Module)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
{devices.map((device, idx) => (
|
||||
<DeviceCard
|
||||
key={`${device.orderId}-${device.deviceId}-${idx}`}
|
||||
device={device}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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('')
|
||||
@@ -751,6 +778,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}
|
||||
@@ -765,6 +803,7 @@ export function OrderWizard({
|
||||
selectedBillingInterval={selectedBillingInterval}
|
||||
billingLabel={billingLabel}
|
||||
billingBadgeClass={billingBadgeClass}
|
||||
existingModuleIds={existingModuleIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}
|
||||
{isExistingLicense ? (
|
||||
<span className="text-slate-500 text-xs">inkl.</span>
|
||||
) : (
|
||||
<>+{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user