387 lines
19 KiB
TypeScript
387 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useMemo } from 'react'
|
|
import Link from 'next/link'
|
|
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package, Search, ChevronLeft, ChevronRight, Building2, Monitor, RefreshCw, FileText } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import type { Order } from '@/lib/types'
|
|
|
|
const statusLabel: Record<string, string> = {
|
|
pending: 'Eingegangen',
|
|
active: 'In Bearbeitung',
|
|
completed: 'Abgeschlossen',
|
|
cancelled: 'Storniert',
|
|
rejected: 'Abgelehnt',
|
|
}
|
|
|
|
const statusClass: Record<string, string> = {
|
|
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
|
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
|
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
|
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
|
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
|
}
|
|
|
|
interface MyOrdersClientProps {
|
|
orders: Order[]
|
|
error: string | null
|
|
}
|
|
|
|
export function MyOrdersClient({ orders, error }: MyOrdersClientProps) {
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const pageSize = 10
|
|
|
|
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
|
|
|
// Volltextsuche filtern
|
|
const filteredOrders = useMemo(() => {
|
|
const term = searchTerm.toLowerCase().trim()
|
|
if (!term) return orders
|
|
|
|
return orders.filter(o => {
|
|
// Anfragenummer
|
|
if (o.order_number?.toString().toLowerCase().includes(term)) return true
|
|
|
|
// Status
|
|
const label = statusLabel[o.status] || o.status
|
|
if (label.toLowerCase().includes(term) || o.status.toLowerCase().includes(term)) return true
|
|
|
|
// Datum
|
|
const dateStr = new Date(o.created_at).toLocaleDateString('de-DE')
|
|
if (dateStr.includes(term)) return true
|
|
|
|
// Endkunde (Firma / Name)
|
|
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
|
if (customer) {
|
|
if (customer.company_name?.toLowerCase().includes(term)) return true
|
|
if (customer.first_name?.toLowerCase().includes(term)) return true
|
|
if (customer.last_name?.toLowerCase().includes(term)) return true
|
|
if (customer.customer_number?.toLowerCase().includes(term)) return true
|
|
}
|
|
|
|
// Notizen
|
|
if (o.notes?.toLowerCase().includes(term)) return true
|
|
|
|
// Kassen & Module
|
|
const items = o.order_data?.items ?? []
|
|
for (const item of items) {
|
|
if (item.device_name?.toLowerCase().includes(term)) return true
|
|
if (item.product_name?.toLowerCase().includes(term)) return true
|
|
if (item.license_number?.toLowerCase().includes(term)) return true
|
|
|
|
for (const mod of item.selected_modules ?? []) {
|
|
if (mod.module_name?.toLowerCase().includes(term)) return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
})
|
|
}, [orders, searchTerm])
|
|
|
|
// Paginierung berechnen
|
|
const totalPages = Math.ceil(filteredOrders.length / pageSize) || 1
|
|
const paginatedOrders = useMemo(() => {
|
|
const start = (currentPage - 1) * pageSize
|
|
return filteredOrders.slice(start, start + pageSize)
|
|
}, [filteredOrders, currentPage, pageSize])
|
|
|
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearchTerm(e.target.value)
|
|
setCurrentPage(1)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
|
<div className="max-w-5xl mx-auto space-y-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/">
|
|
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
|
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
|
</Button>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
|
<ShoppingBag className="w-8 h-8 text-primary" />
|
|
Meine Anfragen
|
|
</h1>
|
|
<p className="text-slate-400 text-sm mt-1">
|
|
Alle Ihre Bestellungen und Konfigurationen auf einen Blick.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Link href="/order">
|
|
<Button className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_20px_rgba(59,130,246,0.3)]">
|
|
+ Neue Anfrage erstellen
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Suche & Statistik */}
|
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Suchen nach Anfragenr., Kunde, Kasse, Produkt, Lizenz..."
|
|
value={searchTerm}
|
|
onChange={handleSearchChange}
|
|
className="pl-10 bg-slate-900/80 border-white/10 text-white placeholder:text-slate-500 rounded-xl h-11 text-sm focus:border-primary"
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-slate-400 bg-slate-900/50 px-4 py-2.5 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
|
|
<span>Anfragen:</span>
|
|
<span className="text-white font-bold text-sm">{filteredOrders.length}</span>
|
|
{searchTerm && <span className="text-slate-500">(gefiltert aus {orders.length})</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fehler */}
|
|
{error && (
|
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
|
Fehler beim Laden der Anfragen: {error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Keine Anfragen (aus DB) */}
|
|
{!error && orders.length === 0 && (
|
|
<Card className="glass-dark border-white/10">
|
|
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
|
<Package className="w-12 h-12 text-slate-600" />
|
|
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
|
<Link href="/order">
|
|
<Button>Jetzt konfigurieren</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Keine Treffer nach Suche */}
|
|
{!error && orders.length > 0 && filteredOrders.length === 0 && (
|
|
<Card className="glass-dark border-white/10">
|
|
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
|
<Search className="w-10 h-10 text-slate-600" />
|
|
<p className="text-slate-300 font-medium">Keine Anfragen für "{searchTerm}" gefunden.</p>
|
|
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
|
|
Filter zurücksetzen
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Anfragenliste */}
|
|
{paginatedOrders.length > 0 && (
|
|
<div className="space-y-4">
|
|
{paginatedOrders.map((o) => {
|
|
const items = o.order_data?.items ?? []
|
|
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
|
|
|
return (
|
|
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-all duration-300 rounded-2xl overflow-hidden shadow-xl">
|
|
<CardContent className="p-6 space-y-5">
|
|
{/* Header-Zeile der Karte */}
|
|
<div className="flex items-start justify-between gap-4 flex-wrap pb-4 border-b border-white/5">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Anfragenummer</span>
|
|
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
|
{statusLabel[o.status] ?? o.status}
|
|
</Badge>
|
|
</div>
|
|
<p className="font-mono font-extrabold text-primary text-xl">#{o.order_number}</p>
|
|
</div>
|
|
|
|
{/* Endkunde Details (falls vorhanden) */}
|
|
{customer && (
|
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 px-3 flex items-center gap-3">
|
|
<Building2 className="w-4 h-4 text-sky-400 shrink-0" />
|
|
<div className="text-xs">
|
|
<p className="text-white font-bold">{customer.company_name || `${customer.first_name} ${customer.last_name}`}</p>
|
|
{(customer.first_name || customer.city) && (
|
|
<p className="text-slate-400 text-[11px]">
|
|
{[customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : null, customer.city].filter(Boolean).join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1 text-right ml-auto">
|
|
<p className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Datum & Preis</p>
|
|
<p className="text-slate-300 text-xs">
|
|
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
})}
|
|
</p>
|
|
<p className="font-extrabold text-white text-lg">
|
|
{fmt(o.total_price)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Kassen & Produkte Detail-Übersicht (Angelehnt an Kundenliste DeviceCard) */}
|
|
{items.length > 0 && (
|
|
<div className="space-y-3 pt-2">
|
|
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
|
<Monitor className="w-3.5 h-3.5 text-primary" />
|
|
Kassenaufstellung ({items.length} {items.length === 1 ? 'Kasse' : 'Kassen'}):
|
|
</p>
|
|
<div className="space-y-2.5">
|
|
{items.map((item, idx) => {
|
|
const devName = item.device_name || `Kasse ${idx + 1}`
|
|
const licNum = item.license_number
|
|
const modules = item.selected_modules || []
|
|
|
|
// Chips für Hauptprodukt und Module
|
|
const chips: string[] = []
|
|
if (item.product_name) chips.push(item.product_name)
|
|
for (const mod of modules) {
|
|
if (mod.module_name) chips.push(mod.module_name)
|
|
}
|
|
|
|
const itemPrice = (item as any).price || item.base_price || 0
|
|
const modulesPrice = modules.reduce((sum: number, m: any) => sum + ((m.price || 0) * (m.quantity || 1)), 0)
|
|
const deviceTotal = itemPrice + modulesPrice
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden"
|
|
>
|
|
{/* Kassen-Info links */}
|
|
<div className="space-y-2 min-w-[200px] flex-1">
|
|
{/* Kassen-Name + Lizenznummer daneben + Intervall */}
|
|
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
|
|
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
|
{devName}
|
|
</span>
|
|
|
|
{licNum && (
|
|
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
|
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
|
{licNum}
|
|
</span>
|
|
)}
|
|
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
|
|
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Produkt + Modul Chips */}
|
|
{chips.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{chips.map((chip, cIdx) => (
|
|
<span
|
|
key={cIdx}
|
|
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 rechts */}
|
|
{deviceTotal > 0 && (
|
|
<div className="text-left md:text-right shrink-0 border-t md:border-t-0 pt-2 md:pt-0 border-white/5">
|
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Kassenwert</p>
|
|
<p className="font-bold text-white text-sm">
|
|
{fmt(deviceTotal)}
|
|
{item.billing_interval === 'monthly' && <span className="text-xs font-normal text-slate-400"> / mtl.</span>}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notizen (falls vorhanden) */}
|
|
{o.notes && (
|
|
<div className="bg-slate-900/40 p-2.5 rounded-xl border border-white/5 text-xs text-slate-400 flex items-start gap-2">
|
|
<FileText className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
|
|
<p><span className="font-semibold text-slate-300">Anmerkungen:</span> {o.notes}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Aktionsleiste */}
|
|
<div className="flex gap-2 flex-wrap pt-2 border-t border-white/5 items-center justify-between">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Link href={`/order/success?id=${o.id}`}>
|
|
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs font-bold rounded-xl h-8">
|
|
Details ansehen
|
|
</Button>
|
|
</Link>
|
|
{o.status !== 'completed' && (
|
|
<Link href={`/order?id=${o.id}`}>
|
|
<Button variant="outline" size="sm" className="border-amber-500/30 hover:bg-amber-500/10 hover:border-amber-500/50 text-amber-400 text-xs font-bold gap-1 rounded-xl h-8">
|
|
<RefreshCw className="w-3 h-3" /> Konfiguration bearbeiten
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
{o.pdf_url && (
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
|
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Ansehen
|
|
</a>
|
|
</Button>
|
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
|
<a href={`/api/orders/${o.id}/download`} download>
|
|
<Download className="w-3.5 h-3.5 mr-1" /> Download
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Paginierung (10 pro Seite) */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
|
<span className="text-xs text-slate-400">
|
|
Seite <span className="text-white font-bold">{currentPage}</span> von <span className="text-white font-bold">{totalPages}</span>
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="border-white/10 text-xs gap-1 rounded-xl"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" /> Vorherige
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="border-white/10 text-xs gap-1 rounded-xl"
|
|
>
|
|
Nächste <ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|