feat(orders): idempotency guard, order numbers, button lock, success page, my-orders, admin polling

This commit is contained in:
DanielS
2026-06-17 01:48:06 +02:00
parent 48920828ea
commit d96be1ace9
10 changed files with 624 additions and 57 deletions

View File

@@ -43,7 +43,7 @@ async function OrdersData() {
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-transparent">
<TableHead className="w-[100px] text-slate-200">ID</TableHead>
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
@@ -61,8 +61,8 @@ async function OrdersData() {
) : (
orders?.map((order) => (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
<TableCell className="font-mono text-xs text-slate-500">
{order.id.slice(0, 8)}...
<TableCell className="font-mono text-xs text-primary font-semibold">
{order.order_number ?? order.id.slice(0, 8) + '...'}
</TableCell>
<TableCell className="text-slate-300">
{new Date(order.created_at).toLocaleDateString('de-DE')}

View File

@@ -1,11 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
import { AdminRecentOrders } from '@/components/admin/recent-orders'
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
async function getKpis() {
const supabase = await createClient()
const [
{ data: orders },
{ count: userCount },
{ count: productCount },
] = await Promise.all([
supabase.from('orders').select('total_price, status'),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('products').select('*', { count: 'exact', head: true }),
])
const totalRevenue = (orders ?? []).reduce((sum, o) => sum + Number(o.total_price), 0)
const orderCount = (orders ?? []).length
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
}
export default async function AdminDashboard() {
const { totalRevenue, orderCount, userCount, productCount } = await getKpis()
export default function AdminDashboard() {
return (
<div className="p-8 space-y-8">
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
{/* KPI Cards echte DB-Daten */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -13,75 +38,49 @@ export default function AdminDashboard() {
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">12,234.56</div>
<p className="text-xs text-slate-400">+20.1% zum Vormonat</p>
<div className="text-2xl font-bold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
</div>
<p className="text-xs text-slate-400">Aus allen Bestellungen</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
<ShoppingCart className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">+573</div>
<p className="text-xs text-slate-400">+12% zur Vorwoche</p>
<div className="text-2xl font-bold text-white">{orderCount}</div>
<p className="text-xs text-slate-400">Gesamt</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">12</div>
<p className="text-xs text-slate-400">+2 neue Module</p>
<div className="text-2xl font-bold text-white">{productCount}</div>
<p className="text-xs text-slate-400">Im Katalog</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
<Users className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">2,450</div>
<p className="text-xs text-slate-400">+180 heute</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4 glass-dark border-white/5">
<CardHeader>
<CardTitle>Übersicht</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
[Umsatz-Chart wird geladen...]
</div>
</CardContent>
</Card>
<Card className="col-span-3 glass-dark border-white/5">
<CardHeader>
<CardTitle>Letzte Bestellungen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-4">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold">
JD
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-white">John Doe</p>
<p className="text-xs text-slate-400">john.doe@example.com</p>
</div>
<div className="font-medium text-white">+450.00</div>
</div>
))}
</div>
<div className="text-2xl font-bold text-white">{userCount}</div>
<p className="text-xs text-slate-400">Registrierte User</p>
</CardContent>
</Card>
</div>
{/* Letzte Bestellungen Client Component mit 30s-Polling */}
<AdminRecentOrders />
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/admin/recent-orders
* Gibt die letzten 10 Bestellungen zurück.
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
* Zugriff nur für authentifizierte User (Supabase Session).
*/
export async function GET() {
const supabase = await createClient()
// Session prüfen
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: orders, error } = await supabase
.from('orders')
.select('id, order_number, created_at, total_price, status, customer_data')
.order('created_at', { ascending: false })
.limit(10)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ orders })
}

158
shop/app/my-orders/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import type { Order } from '@/lib/types'
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
cancelled: 'Storniert',
}
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',
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
}
export default async function MyOrdersPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const { data: orders, error } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<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 Bestellungen
</h1>
<p className="text-slate-400 text-sm mt-1">
Alle Ihre Bestellungen auf einen Blick inkl. aktuellem Status.
</p>
</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 Bestellungen: {error.message}
</div>
)}
{/* Keine Bestellungen */}
{!error && (!orders || 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 Bestellungen aufgegeben.</p>
<Link href="/order">
<Button>Jetzt konfigurieren</Button>
</Link>
</CardContent>
</Card>
)}
{/* Bestellliste */}
<div className="space-y-4">
{(orders ?? []).map((order) => {
const o = order as Order
const items = o.order_data?.items ?? []
return (
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-colors">
<CardContent className="p-6 space-y-4">
{/* Kopfzeile */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wider">Bestellnummer</p>
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-slate-500 uppercase tracking-wider">Datum</p>
<p className="text-white text-sm">
{new Date(o.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
{statusLabel[o.status] ?? o.status}
</Badge>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-slate-500 uppercase tracking-wider">Gesamt</p>
<p className="font-bold text-white text-lg">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
</p>
</div>
</div>
{/* Produkte kurz */}
{items.length > 0 && (
<div className="flex flex-wrap gap-2">
{items.map((item, i) => (
<span
key={i}
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 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>
)}
{/* Aktionen */}
<div className="flex gap-2 flex-wrap pt-1">
<Link href={`/order/success?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs">
Details ansehen
</Button>
</Link>
{o.pdf_url && (
<>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 mr-1" /> Rechnung ansehen
</a>
</Button>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={o.pdf_url} download>
<Download className="w-3 h-3 mr-1" /> PDF
</a>
</Button>
</>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import type { Order } from '@/lib/types'
export default async function OrderSuccessPage({
searchParams,
}: {
searchParams: Promise<{ id?: string }>
}) {
const { id } = await searchParams
if (!id) redirect('/')
const supabase = await createClient()
// Authentifizierung prüfen
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
// Bestellung laden und prüfen ob sie dem eingeloggten User gehört
const { data: order, error } = await supabase
.from('orders')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !order) redirect('/')
const o = order as Order
const items = o.order_data?.items ?? []
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
cancelled: 'Storniert',
}
return (
<div className="min-h-screen bg-[#020617] text-white flex items-center justify-center px-4 py-16">
<div className="w-full max-w-2xl space-y-6">
{/* Erfolgsmeldung */}
<div className="text-center space-y-4">
<div className="w-24 h-24 bg-green-500/10 rounded-full flex items-center justify-center mx-auto border border-green-500/30 animate-pulse">
<CheckCircle2 className="w-12 h-12 text-green-400" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Bestellung eingegangen!
</h1>
<p className="text-slate-400 text-lg">
Ihre Bestellung wurde erfolgreich gespeichert.
</p>
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
#{o.order_number}
</div>
</div>
{/* Bestell-Details */}
<Card className="glass-dark border-white/10">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white">Bestellübersicht</CardTitle>
<Badge
className={
o.status === 'active'
? 'bg-green-500/20 text-green-400 border-green-500/30'
: o.status === 'cancelled'
? 'bg-red-500/20 text-red-400 border-red-500/30'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
}
>
{statusLabel[o.status] ?? o.status}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{/* Produkte */}
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
<span className="text-slate-400 text-xs ml-1">
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
</span>
</span>
</div>
{item.selected_modules.map((mod, j) => (
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
</div>
))}
</div>
))}
</div>
<Separator className="bg-white/10" />
{/* Gesamtbetrag */}
<div className="flex justify-between text-lg font-bold">
<span className="text-slate-300">Gesamtbetrag</span>
<span className="text-gradient">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
</span>
</div>
<Separator className="bg-white/10" />
{/* Kundendaten */}
<div className="text-sm text-slate-400 space-y-1">
<p className="font-semibold text-white">{o.customer_data?.company_name}</p>
<p>{o.customer_data?.first_name} {o.customer_data?.last_name}</p>
<p>{o.customer_data?.address}, {o.customer_data?.zip_code} {o.customer_data?.city}</p>
</div>
{/* PDF-Download */}
{o.pdf_url && (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" /> Rechnung ansehen
</a>
</Button>
<Button variant="secondary" size="sm" asChild>
<a href={o.pdf_url} download>
<Download className="w-4 h-4 mr-2" /> Herunterladen
</a>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Aktions-Buttons */}
<div className="flex gap-3">
<Link href="/my-orders" className="flex-1">
<Button variant="outline" className="w-full border-white/10 hover:bg-white/5">
<ShoppingBag className="w-4 h-4 mr-2" />
Meine Bestellungen
</Button>
</Link>
<Link href="/" className="flex-1">
<Button className="w-full">
Zurück zur Startseite <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -22,6 +22,9 @@ export default function Home() {
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
Bestellen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-orders">
Meine Bestellungen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
Admin
</Link>