feat(orders): idempotency guard, order numbers, button lock, success page, my-orders, admin polling
This commit is contained in:
@@ -43,7 +43,7 @@ async function OrdersData() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-transparent">
|
<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">Datum</TableHead>
|
||||||
<TableHead className="text-slate-200">Kunde</TableHead>
|
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||||
<TableHead className="text-slate-200">Betrag</TableHead>
|
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||||
@@ -61,8 +61,8 @@ async function OrdersData() {
|
|||||||
) : (
|
) : (
|
||||||
orders?.map((order) => (
|
orders?.map((order) => (
|
||||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
|
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
|
||||||
<TableCell className="font-mono text-xs text-slate-500">
|
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||||
{order.id.slice(0, 8)}...
|
{order.order_number ?? order.id.slice(0, 8) + '...'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-slate-300">
|
<TableCell className="text-slate-300">
|
||||||
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
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 (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
|
<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">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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" />
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">€12,234.56</div>
|
<div className="text-2xl font-bold text-white">
|
||||||
<p className="text-xs text-slate-400">+20.1% zum Vormonat</p>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">Aus allen Bestellungen</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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>
|
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">+573</div>
|
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||||
<p className="text-xs text-slate-400">+12% zur Vorwoche</p>
|
<p className="text-xs text-slate-400">Gesamt</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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>
|
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||||
<Package className="h-4 w-4 text-primary" />
|
<Package className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">12</div>
|
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||||
<p className="text-xs text-slate-400">+2 neue Module</p>
|
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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>
|
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||||
<Users className="h-4 w-4 text-primary" />
|
<Users className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">2,450</div>
|
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||||
<p className="text-xs text-slate-400">+180 heute</p>
|
<p className="text-xs text-slate-400">Registrierte User</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Letzte Bestellungen – Client Component mit 30s-Polling */}
|
||||||
|
<AdminRecentOrders />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
30
shop/app/api/admin/recent-orders/route.ts
Normal file
30
shop/app/api/admin/recent-orders/route.ts
Normal 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
158
shop/app/my-orders/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
shop/app/order/success/page.tsx
Normal file
157
shop/app/order/success/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export default function Home() {
|
|||||||
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
|
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
|
||||||
Bestellen
|
Bestellen
|
||||||
</Link>
|
</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">
|
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
|
||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
140
shop/components/admin/recent-orders.tsx
Normal file
140
shop/components/admin/recent-orders.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type RecentOrder = {
|
||||||
|
id: string
|
||||||
|
order_number: string
|
||||||
|
created_at: string
|
||||||
|
total_price: number
|
||||||
|
status: string
|
||||||
|
customer_data?: {
|
||||||
|
company_name?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
export function AdminRecentOrders() {
|
||||||
|
const [orders, setOrders] = useState<RecentOrder[]>([])
|
||||||
|
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
||||||
|
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
||||||
|
const knownIds = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const fetchOrders = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const { orders: fresh } = await res.json()
|
||||||
|
|
||||||
|
// Neue Bestellungen erkennen (Highlight)
|
||||||
|
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
||||||
|
const newOnes = new Set<string>()
|
||||||
|
freshIds.forEach(id => {
|
||||||
|
if (!knownIds.current.has(id)) newOnes.add(id)
|
||||||
|
})
|
||||||
|
knownIds.current = freshIds
|
||||||
|
|
||||||
|
setOrders(fresh)
|
||||||
|
setNewOrderIds(newOnes)
|
||||||
|
setLastChecked(new Date())
|
||||||
|
|
||||||
|
// Highlight nach 5 Sekunden entfernen
|
||||||
|
if (newOnes.size > 0) {
|
||||||
|
setTimeout(() => setNewOrderIds(new Set()), 5000)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Polling error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders()
|
||||||
|
const interval = setInterval(fetchOrders, POLL_INTERVAL_MS)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 – folgt in nächstem Update]
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="col-span-3 glass-dark border-white/5">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<CardTitle>Neue Bestellungen</CardTitle>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
Aktualisiert: {lastChecked.toLocaleTimeString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic text-center py-8">Noch keine Bestellungen</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orders.map(order => {
|
||||||
|
const isNew = newOrderIds.has(order.id)
|
||||||
|
const customerName =
|
||||||
|
order.customer_data?.company_name ||
|
||||||
|
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
|
||||||
|
'Unbekannt'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={order.id} href="/admin/orders" className="block">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${
|
||||||
|
isNew
|
||||||
|
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
|
||||||
|
{customerName.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{customerName}</p>
|
||||||
|
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 space-y-1">
|
||||||
|
<p className="font-medium text-white text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||||
|
</p>
|
||||||
|
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>
|
||||||
|
{statusLabel[order.status] ?? order.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Product, ProductModule, Profile } from '@/lib/types'
|
import { Product, ProductModule, Profile } from '@/lib/types'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||||
@@ -10,7 +11,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle } from 'lucide-react'
|
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
import { submitOrder } from '@/lib/actions/orders'
|
import { submitOrder } from '@/lib/actions/orders'
|
||||||
import { Category } from '@/lib/types'
|
import { Category } from '@/lib/types'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -76,7 +77,9 @@ export function OrderWizard({
|
|||||||
categories: Category[]
|
categories: Category[]
|
||||||
initialProfile: Profile | null
|
initialProfile: Profile | null
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [customerData, setCustomerData] = useState<Partial<Profile>>(
|
const [customerData, setCustomerData] = useState<Partial<Profile>>(
|
||||||
initialProfile || {
|
initialProfile || {
|
||||||
company_name: '',
|
company_name: '',
|
||||||
@@ -145,18 +148,20 @@ export function OrderWizard({
|
|||||||
const prevStep = () => setStep(s => s - 1)
|
const prevStep = () => setStep(s => s - 1)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (isSubmitting) return // Doppelklick-Guard
|
||||||
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await submitOrder({
|
const order = await submitOrder({
|
||||||
selections,
|
selections,
|
||||||
products,
|
products,
|
||||||
categories,
|
categories,
|
||||||
customerProfile: customerData,
|
customerProfile: customerData,
|
||||||
})
|
})
|
||||||
alert('Bestellung erfolgreich aufgegeben!')
|
router.push(`/order/success?id=${order.id}`)
|
||||||
window.location.href = '/'
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
alert('Fehler bei der Bestellung.')
|
alert('Fehler bei der Bestellung. Bitte versuchen Sie es erneut.')
|
||||||
|
setIsSubmitting(false) // Nur bei Fehler wieder entsperren
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,8 +557,13 @@ export function OrderWizard({
|
|||||||
<Button
|
<Button
|
||||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Kostenpflichtig bestellen
|
{isSubmitting ? (
|
||||||
|
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Bestellung wird verarbeitet...</>
|
||||||
|
) : (
|
||||||
|
'Kostenpflichtig bestellen'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={prevStep} className="w-full">
|
<Button variant="ghost" onClick={prevStep} className="w-full">
|
||||||
Noch etwas ändern
|
Noch etwas ändern
|
||||||
|
|||||||
@@ -8,11 +8,43 @@ import React from 'react'
|
|||||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||||
import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||||
|
|
||||||
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||||
|
* Format: RE-YYYY-NNNNN (z.B. RE-2026-84731)
|
||||||
|
* Kein Rückschluss auf Gesamtanzahl der Bestellungen möglich.
|
||||||
|
*/
|
||||||
|
function generateOrderNumber(): string {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
||||||
|
return `RE-${year}-${rand}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen einfachen deterministischen Hash aus dem Bestell-Snapshot.
|
||||||
|
* Wird für den Idempotenz-Guard verwendet (verhindert Doppelbestellungen).
|
||||||
|
*/
|
||||||
|
function hashOrderSnapshot(snapshot: object): string {
|
||||||
|
const str = JSON.stringify(snapshot)
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i)
|
||||||
|
hash = ((hash << 5) - hash) + char
|
||||||
|
hash = hash & hash // 32-bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
|
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
|
||||||
* generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
|
* generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
|
||||||
|
*
|
||||||
|
* Enthält Idempotenz-Guard: Wenn innerhalb von 30 Sekunden eine identische
|
||||||
|
* Bestellung vom gleichen User existiert, wird diese zurückgegeben statt
|
||||||
|
* einer neuen erstellt (Schutz gegen Doppelklick / Netzwerk-Retry).
|
||||||
*/
|
*/
|
||||||
export async function submitOrder(params: {
|
export async function submitOrder(params: {
|
||||||
selections: WizardSelections
|
selections: WizardSelections
|
||||||
@@ -30,11 +62,46 @@ export async function submitOrder(params: {
|
|||||||
const customerSnapshot = buildCustomerSnapshot(customerProfile)
|
const customerSnapshot = buildCustomerSnapshot(customerProfile)
|
||||||
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
||||||
|
|
||||||
// 2. Bestellung in DB schreiben
|
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||||
|
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||||
|
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString()
|
||||||
|
|
||||||
|
const { data: existingOrder } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('order_hash', orderHash)
|
||||||
|
.gte('created_at', thirtySecondsAgo)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (existingOrder) {
|
||||||
|
console.warn('[submitOrder] Duplicate order detected, returning existing order:', existingOrder.order_number)
|
||||||
|
return existingOrder as Order
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Eindeutige Bestellnummer generieren (Retry bei Kollision)
|
||||||
|
let orderNumber: string = ''
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const candidate = generateOrderNumber()
|
||||||
|
const { data: collision } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('id')
|
||||||
|
.eq('order_number', candidate)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!collision) {
|
||||||
|
orderNumber = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!orderNumber) throw new Error('Konnte keine eindeutige Bestellnummer generieren.')
|
||||||
|
|
||||||
|
// 4. Bestellung in DB schreiben
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.insert([{
|
.insert([{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
order_number: orderNumber,
|
||||||
|
order_hash: orderHash,
|
||||||
total_price: orderSnapshot.total,
|
total_price: orderSnapshot.total,
|
||||||
customer_data: customerSnapshot,
|
customer_data: customerSnapshot,
|
||||||
order_data: orderSnapshot,
|
order_data: orderSnapshot,
|
||||||
@@ -45,7 +112,7 @@ export async function submitOrder(params: {
|
|||||||
|
|
||||||
if (orderError) throw orderError
|
if (orderError) throw orderError
|
||||||
|
|
||||||
// 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
||||||
try {
|
try {
|
||||||
const buffer = await renderToBuffer(
|
const buffer = await renderToBuffer(
|
||||||
React.createElement(InvoicePDF, {
|
React.createElement(InvoicePDF, {
|
||||||
@@ -55,7 +122,7 @@ export async function submitOrder(params: {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. PDF in Supabase Storage hochladen
|
// 6. PDF in Supabase Storage hochladen
|
||||||
const fileName = `invoice_${order.id}.pdf`
|
const fileName = `invoice_${order.id}.pdf`
|
||||||
const { error: uploadError } = await supabase
|
const { error: uploadError } = await supabase
|
||||||
.storage
|
.storage
|
||||||
@@ -67,12 +134,13 @@ export async function submitOrder(params: {
|
|||||||
} else {
|
} else {
|
||||||
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
||||||
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||||
|
order.pdf_url = publicUrlData.publicUrl
|
||||||
}
|
}
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
console.error('PDF Generation Error:', pdfError)
|
console.error('PDF Generation Error:', pdfError)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/protected')
|
revalidatePath('/protected')
|
||||||
|
revalidatePath('/my-orders')
|
||||||
return order as Order
|
return order as Order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export type OrderSnapshot = {
|
|||||||
export type Order = {
|
export type Order = {
|
||||||
id: string
|
id: string
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
|
order_number: string
|
||||||
|
order_hash: string | null
|
||||||
customer_data: CustomerSnapshot
|
customer_data: CustomerSnapshot
|
||||||
order_data: OrderSnapshot
|
order_data: OrderSnapshot
|
||||||
total_price: number
|
total_price: number
|
||||||
|
|||||||
Reference in New Issue
Block a user