Compare commits
2 Commits
c6190b34a8
...
a43d598f54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43d598f54 | ||
|
|
0e4495292d |
@@ -1,8 +1,8 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getPartnerCustomersWithOrders } from '@/lib/actions/queries'
|
import { getPartnerCustomersWithDevices } from '@/lib/actions/queries'
|
||||||
import type { EndCustomerWithOrders } from '@/lib/types'
|
import type { EndCustomerWithDevices } from '@/lib/types'
|
||||||
import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react'
|
import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
@@ -13,10 +13,10 @@ export default async function MyCustomersPage() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) redirect('/auth/login')
|
if (!user) redirect('/auth/login')
|
||||||
|
|
||||||
let customers: EndCustomerWithOrders[] = []
|
let customers: EndCustomerWithDevices[] = []
|
||||||
let fetchError: string | null = null
|
let fetchError: string | null = null
|
||||||
try {
|
try {
|
||||||
customers = await getPartnerCustomersWithOrders()
|
customers = await getPartnerCustomersWithDevices()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error loading partner customers with orders:", err)
|
console.error("Error loading partner customers with orders:", err)
|
||||||
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen."
|
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'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
interface PageProps {
|
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) {
|
export default async function OrderPage({ searchParams }: PageProps) {
|
||||||
@@ -27,14 +27,29 @@ export default async function OrderPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
|
<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>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</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 supabase = await createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
@@ -84,7 +99,7 @@ async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: st
|
|||||||
redirect('/order')
|
redirect('/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderData.status === 'completed' && !isAdmin && mode !== 'extension') {
|
if (orderData.status === 'completed' && !isAdmin && mode !== 'extension' && mode !== 'upgrade') {
|
||||||
redirect('/order')
|
redirect('/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +154,9 @@ async function OrderDataWrapper({ orderId, mode }: { orderId?: string; mode?: st
|
|||||||
initialOrder={initialOrder}
|
initialOrder={initialOrder}
|
||||||
isAdmin={userData?.role === 'admin'}
|
isAdmin={userData?.role === 'admin'}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
upgradeMode={mode === 'upgrade'}
|
||||||
|
initialEndCustomerId={customerId || null}
|
||||||
|
lockedDeviceId={deviceId ? decodeURIComponent(deviceId) : null}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { EndCustomerWithOrders } from '@/lib/types'
|
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Search,
|
Search,
|
||||||
|
Package,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -40,12 +41,134 @@ const statusClass: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerAccordionListProps {
|
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) {
|
export function CustomerAccordionList({ customers }: CustomerAccordionListProps) {
|
||||||
const [openCustomerIds, setOpenCustomerIds] = useState<Record<string, boolean>>(() => {
|
const [openCustomerIds, setOpenCustomerIds] = useState<Record<string, boolean>>(() => {
|
||||||
// Default open all customers with orders
|
|
||||||
const initial: Record<string, boolean> = {}
|
const initial: Record<string, boolean> = {}
|
||||||
customers.forEach((c) => {
|
customers.forEach((c) => {
|
||||||
initial[c.id] = true
|
initial[c.id] = true
|
||||||
@@ -91,7 +214,7 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Suche */}
|
{/* Suchfeld */}
|
||||||
<div className="relative max-w-md">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
@@ -103,18 +226,18 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accordion List */}
|
{/* Akkordeon-Liste */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredCustomers.map((customer) => {
|
{filteredCustomers.map((customer) => {
|
||||||
const isOpen = !!openCustomerIds[customer.id]
|
const isOpen = !!openCustomerIds[customer.id]
|
||||||
const orders = customer.orders || []
|
const devices = customer.devices || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
|
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
|
||||||
>
|
>
|
||||||
{/* Accordion Header */}
|
{/* Akkordeon-Header */}
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleCustomer(customer.id)}
|
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"
|
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>
|
</div>
|
||||||
<p className="text-sm text-slate-400">
|
<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.city ? ` • ${customer.city}` : ''}
|
||||||
{customer.email ? ` • ${customer.email}` : ''}
|
{customer.email ? ` • ${customer.email}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -141,9 +265,10 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-auto">
|
<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">
|
<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" />
|
<Package className="w-3.5 h-3.5 mr-1 text-primary" />
|
||||||
{orders.length} {orders.length === 1 ? 'Kasse / Bestellung' : 'Kassen / Bestellungen'}
|
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{!customer.is_anonymized && (
|
{!customer.is_anonymized && (
|
||||||
@@ -166,139 +291,37 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-slate-400 hover:text-white p-1"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accordion Content (Kassen/Orders) */}
|
{/* Ausgeklappter Bereich mit Kassen */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-white/5 bg-slate-950/40 p-5 space-y-3">
|
<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">
|
<div className="text-slate-500 text-sm py-4 text-center">
|
||||||
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
|
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
|
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
|
||||||
Zugeordnete Kassen & Systeme ({orders.length})
|
Zugeordnete Kassen ({devices.length})
|
||||||
</p>
|
</p>
|
||||||
{orders.map((order) => {
|
{devices.map((device, idx) => (
|
||||||
const items = order.order_data?.items || []
|
<DeviceCard
|
||||||
|
key={`${device.orderId}-${device.deviceId}-${idx}`}
|
||||||
return (
|
device={device}
|
||||||
<div
|
customerId={customer.id}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export function OrderWizard({
|
|||||||
initialOrder,
|
initialOrder,
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
companies = [],
|
companies = [],
|
||||||
|
upgradeMode = false,
|
||||||
|
initialEndCustomerId = null,
|
||||||
|
lockedDeviceId = null,
|
||||||
}: {
|
}: {
|
||||||
products: Product[]
|
products: Product[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
@@ -82,9 +85,13 @@ export function OrderWizard({
|
|||||||
initialOrder?: Order | null
|
initialOrder?: Order | null
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
companies?: any[]
|
companies?: any[]
|
||||||
|
upgradeMode?: boolean
|
||||||
|
initialEndCustomerId?: string | null
|
||||||
|
lockedDeviceId?: string | null
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(
|
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(
|
||||||
initialOrder?.company_id ?? (isAdmin ? 'all' : 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 [editingIdx, setEditingIdx] = useState<number | null>(null)
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' } | null>(null)
|
const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' } | null>(null)
|
||||||
|
|
||||||
@@ -154,11 +180,12 @@ export function OrderWizard({
|
|||||||
|
|
||||||
// Endkunden-State
|
// Endkunden-State
|
||||||
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
|
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
|
||||||
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
|
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(() => {
|
||||||
initialOrder
|
// Upgrade-Mode: Kundenauswahl aus URL-Param
|
||||||
? initialOrder.end_customer_id
|
if (upgradeMode && initialEndCustomerId) return initialEndCustomerId
|
||||||
: (initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null)
|
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 selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
@@ -839,6 +866,17 @@ export function OrderWizard({
|
|||||||
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
|
<ProgressStepper step={step} basketItemsCount={basketItems.length} />
|
||||||
</div>
|
</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 */}
|
{/* Scrollable category content */}
|
||||||
<StepSoftware
|
<StepSoftware
|
||||||
visibleCategories={visibleCategories}
|
visibleCategories={visibleCategories}
|
||||||
@@ -853,6 +891,7 @@ export function OrderWizard({
|
|||||||
selectedBillingInterval={selectedBillingInterval}
|
selectedBillingInterval={selectedBillingInterval}
|
||||||
billingLabel={billingLabel}
|
billingLabel={billingLabel}
|
||||||
billingBadgeClass={billingBadgeClass}
|
billingBadgeClass={billingBadgeClass}
|
||||||
|
existingModuleIds={existingModuleIds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 * as Icons from 'lucide-react'
|
||||||
import { Category, Product, CategorySelection } from '@/lib/types'
|
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ interface StepSoftwareProps {
|
|||||||
selectedBillingInterval: 'one_time' | 'monthly'
|
selectedBillingInterval: 'one_time' | 'monthly'
|
||||||
billingLabel: (interval: string) => string
|
billingLabel: (interval: string) => string
|
||||||
billingBadgeClass: (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 }) {
|
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||||
@@ -46,6 +48,7 @@ export function StepSoftware({
|
|||||||
selectedBillingInterval,
|
selectedBillingInterval,
|
||||||
billingLabel,
|
billingLabel,
|
||||||
billingBadgeClass,
|
billingBadgeClass,
|
||||||
|
existingModuleIds = [],
|
||||||
}: StepSoftwareProps) {
|
}: StepSoftwareProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="glass-dark border-white/10">
|
<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">
|
<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>
|
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
|
||||||
{selectedProduct.modules.map(module => {
|
{selectedProduct.modules.map(module => {
|
||||||
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
|
const isExistingLicense = existingModuleIds.includes(module.id)
|
||||||
const checked = sel?.moduleIds.includes(module.id) ?? false
|
const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? [])
|
||||||
|
const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={module.id}
|
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">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`mod-${cat.id}-${module.id}`}
|
id={`mod-${cat.id}-${module.id}`}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
onCheckedChange={() => !isExistingLicense && toggleModule(cat.id, module.id)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`mod-${cat.id}-${module.id}`}
|
htmlFor={isExistingLicense ? undefined : `mod-${cat.id}-${module.id}`}
|
||||||
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
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">
|
<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',
|
style: 'currency',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
}).format(module.price)}
|
}).format(module.price)}</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
{module.description && (
|
{module.description && (
|
||||||
<p className="text-xs text-slate-400">{module.description}</p>
|
<p className="text-xs text-slate-400">{module.description}</p>
|
||||||
)}
|
)}
|
||||||
{disabled && (
|
{!isExistingLicense && disabled && (
|
||||||
<p className="text-[10px] text-destructive mt-1">
|
<p className="text-[10px] text-destructive mt-1">
|
||||||
{module.requirements?.length && !module.requirements.some(
|
{module.requirements?.length && !module.requirements.some(
|
||||||
reqId => sel?.moduleIds.includes(reqId)
|
reqId => sel?.moduleIds.includes(reqId)
|
||||||
@@ -258,7 +281,7 @@ export function StepSoftware({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scalable Quantity */}
|
{/* 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">
|
<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>
|
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { createAdminClient } from '@/lib/supabase/admin'
|
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.
|
* 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 {
|
export interface GetCustomersForWizardParams {
|
||||||
partnerCompanyId?: string
|
partnerCompanyId?: string
|
||||||
page?: number
|
page?: number
|
||||||
|
|||||||
@@ -179,6 +179,30 @@ export type EndCustomerWithOrders = EndCustomer & {
|
|||||||
orders: OrderWithRegisterName[]
|
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) ───────────────────────────────────
|
// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────
|
||||||
|
|
||||||
export type LicenseOption = {
|
export type LicenseOption = {
|
||||||
|
|||||||
Reference in New Issue
Block a user