Compare commits

...

10 Commits

38 changed files with 2916 additions and 583 deletions

View File

@@ -37,10 +37,6 @@ async function CategoryDataWrapper() {
async function CreateCategoryAction() {
return (
<CategoryDialog>
<button className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
<Plus className="mr-2 h-4 w-4" /> Kategorie hinzufügen
</button>
</CategoryDialog>
<CategoryDialog />
)
}

View File

@@ -36,6 +36,10 @@ export default function AdminLayout({
<ShoppingCart className="w-5 h-5" />
Bestellungen
</Link>
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Users className="w-5 h-5" />
Benutzer
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Settings className="w-5 h-5" />
Einstellungen

View File

@@ -13,8 +13,8 @@ export default async function AdminOrdersPage() {
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Bestellungen & Rechnungen</h1>
<p className="text-muted-foreground">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
<h1 className="text-3xl font-bold tracking-tight text-white">Bestellungen & Rechnungen</h1>
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
</div>
</div>
@@ -43,12 +43,12 @@ async function OrdersData() {
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-transparent">
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Kunde</TableHead>
<TableHead>Betrag</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Rechnung</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>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Rechnung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -61,17 +61,17 @@ 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-muted-foreground">
{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>
<TableCell className="text-slate-300">
{new Date(order.created_at).toLocaleDateString('de-DE')}
</TableCell>
<TableCell>
<div className="font-medium">{order.customer_data?.company_name || 'Privatkunde'}</div>
<div className="text-xs text-muted-foreground">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
<div className="font-medium text-white">{order.customer_data?.company_name || 'Privatkunde'}</div>
<div className="text-xs text-slate-400">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
</TableCell>
<TableCell className="font-semibold">
<TableCell className="font-semibold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>

View File

@@ -1,87 +1,86 @@
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">
<CardTitle className="text-sm font-medium">Umsatz gesamt</CardTitle>
<CardTitle className="text-sm font-medium text-slate-300">Umsatz gesamt</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12,234.56</div>
<p className="text-xs text-muted-foreground">+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">Bestellungen</CardTitle>
<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">+573</div>
<p className="text-xs text-muted-foreground">+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">Produkte</CardTitle>
<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">12</div>
<p className="text-xs text-muted-foreground">+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">Kunden</CardTitle>
<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">2,450</div>
<p className="text-xs text-muted-foreground">+180 heute</p>
<div className="text-2xl font-bold text-white">{userCount}</div>
<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-muted-foreground">
[Chart Platzhalter]
</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">John Doe</p>
<p className="text-xs text-muted-foreground">john.doe@example.com</p>
</div>
<div className="font-medium">+450.00</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Letzte Bestellungen Client Component mit 30s-Polling */}
<AdminRecentOrders />
</div>
)
}

View File

@@ -14,7 +14,7 @@ export default async function AdminProductsPage() {
<Package className="w-8 h-8 text-primary" />
Produkte & Module
</h2>
<p className="text-muted-foreground">
<p className="text-slate-300">
Verwalte deine Hauptprodukte und deren zubuchbare Module.
</p>
</div>

View File

@@ -0,0 +1,42 @@
import { getUsers } from '@/lib/actions/users'
import { UserList } from '@/components/admin/user-list'
import { UserDialog } from '@/components/admin/user-dialog'
import { Users } from 'lucide-react'
import { Suspense } from 'react'
export default async function AdminUsersPage() {
return (
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<Users className="w-8 h-8 text-primary" />
Benutzerverwaltung
</h2>
<p className="text-slate-400">
Verwalten Sie Benutzerkonten, Berechtigungen und Sicherheit.
</p>
</div>
<div className="flex items-center space-x-2">
<UserDialog />
</div>
</div>
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
<UserDataWrapper />
</Suspense>
</div>
)
}
async function UserDataWrapper() {
try {
const users = await getUsers()
return <UserList initialUsers={users} />
} catch (error) {
return (
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center">
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.
</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 })
}

View File

@@ -9,7 +9,7 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: "CASPOSShop | Modulare Software-Lösungen",
title: "CASPOS-Shop | Die Kasse",
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
};

View File

@@ -0,0 +1,213 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Save, AlertTriangle, Loader2, Building2, ShoppingBag, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getEndCustomer, updateEndCustomer, anonymizeEndCustomer } from '@/lib/actions/end-customers'
import type { EndCustomer } from '@/lib/types'
export default function CustomerDetailPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter()
const [customer, setCustomer] = useState<EndCustomer | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [anonymizing, setAnonymizing] = useState(false)
const [showGdprConfirm, setShowGdprConfirm] = useState(false)
const [form, setForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
})
useEffect(() => {
params.then(({ id }) => {
getEndCustomer(id).then(c => {
if (!c) { router.push('/my-customers'); return }
setCustomer(c)
setForm({
company_name: c.company_name ?? '',
vat_id: c.vat_id ?? '',
first_name: c.first_name ?? '',
last_name: c.last_name ?? '',
street: c.street ?? '',
zip: c.zip ?? '',
city: c.city ?? '',
})
setLoading(false)
})
})
}, [params, router])
const handleSave = async () => {
if (!customer || !form.company_name.trim()) return
setSaving(true)
try {
await updateEndCustomer(customer.id, form)
setCustomer(prev => prev ? { ...prev, ...form } : prev)
alert('Kundendaten gespeichert.')
} catch (e) {
alert('Fehler beim Speichern.')
} finally {
setSaving(false)
}
}
const handleAnonymize = async () => {
if (!customer) return
setAnonymizing(true)
try {
await anonymizeEndCustomer(customer.id)
router.push('/my-customers')
} catch (e) {
alert('Fehler bei der Anonymisierung.')
setAnonymizing(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-[#020617] flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
)
}
if (!customer) return null
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/my-customers">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Meine Kunden
</Button>
</Link>
<div>
<h1 className="text-2xl font-extrabold tracking-tight flex items-center gap-2">
<Building2 className="w-6 h-6 text-primary" />
{customer.is_anonymized ? '[Anonymisiert]' : customer.company_name}
</h1>
<p className="text-slate-400 text-xs mt-0.5">
Kunden-ID: <span className="font-mono">{customer.id.slice(0, 8)}</span>
</p>
</div>
{customer.is_anonymized && (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1 ml-auto">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
</Badge>
)}
</div>
{/* Bearbeitungsformular */}
{!customer.is_anonymized && (
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-white">Stammdaten bearbeiten</CardTitle>
<CardDescription className="text-slate-400">
Änderungen wirken sich nur auf zukünftige Bestellungen aus. Bestehende Snapshots bleiben unverändert.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
{([
{ label: 'Firmenname *', key: 'company_name', span: true },
{ label: 'USt-IdNr.', key: 'vat_id', span: false },
{ label: 'Vorname', key: 'first_name', span: false },
{ label: 'Nachname', key: 'last_name', span: false },
{ label: 'Straße & Hausnummer', key: 'street', span: true },
{ label: 'PLZ', key: 'zip', span: false },
{ label: 'Ort', key: 'city', span: false },
] as const).map(({ label, key, span }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>
<Input
value={form[key]}
onChange={e => setForm(prev => ({ ...prev, [key]: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
/>
</div>
))}
</div>
<div className="flex gap-3 pt-2">
<Button onClick={handleSave} disabled={saving || !form.company_name.trim()} className="gap-2">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Speichern
</Button>
<Link href={`/my-orders`}>
<Button variant="outline" className="border-white/10 hover:bg-white/5 gap-2">
<ShoppingBag className="w-4 h-4" /> Bestellungen ansehen
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* DSGVO Anonymisierung */}
{!customer.is_anonymized && (
<Card className="glass-dark border-red-500/20 bg-red-500/5">
<CardHeader>
<CardTitle className="text-red-400 flex items-center gap-2">
<Trash2 className="w-5 h-5" />
Recht auf Vergessenwerden (DSGVO)
</CardTitle>
<CardDescription className="text-slate-400">
Auf Wunsch des Endkunden können Sie dessen personenbezogene Daten unwiderruflich anonymisieren.
Bestehende Bestelldaten (PDF-Rechnungen, Snapshots) werden aus steuerrechtlichen Gründen
<strong className="text-white"> nicht </strong> gelöscht.
</CardDescription>
</CardHeader>
<CardContent>
{!showGdprConfirm ? (
<Button
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10 gap-2"
onClick={() => setShowGdprConfirm(true)}
>
<Trash2 className="w-4 h-4" /> Kundendaten anonymisieren
</Button>
) : (
<div className="space-y-3 p-4 rounded-lg bg-red-500/10 border border-red-500/30">
<p className="text-sm text-red-300 font-semibold">
Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.
</p>
<div className="flex gap-3">
<Button
onClick={handleAnonymize}
disabled={anonymizing}
className="bg-red-600 hover:bg-red-700 gap-2"
>
{anonymizing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Ja, unwiderruflich anonymisieren
</Button>
<Button variant="ghost" onClick={() => setShowGdprConfirm(false)}>
Abbrechen
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Bereits anonymisiert */}
{customer.is_anonymized && (
<Card className="glass-dark border-white/10">
<CardContent className="py-8 text-center space-y-2">
<AlertTriangle className="w-10 h-10 text-amber-400 mx-auto" />
<p className="text-slate-300">Dieser Endkunde wurde bereits anonymisiert.</p>
<p className="text-slate-500 text-sm">Die Daten können nicht wiederhergestellt werden.</p>
</CardContent>
</Card>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, UserPlus, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createEndCustomer } from '@/lib/actions/end-customers'
export default function NewCustomerPage() {
const router = useRouter()
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
})
const handleSave = async () => {
if (!form.company_name.trim()) return
setSaving(true)
try {
await createEndCustomer(form)
router.push('/my-customers')
} catch (e) {
alert('Fehler beim Anlegen des Kunden.')
setSaving(false)
}
}
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<Link href="/my-customers">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Zurück
</Button>
</Link>
<h1 className="text-2xl font-extrabold tracking-tight flex items-center gap-2">
<UserPlus className="w-6 h-6 text-primary" />
Neuen Endkunden anlegen
</h1>
</div>
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-white">Stammdaten</CardTitle>
<CardDescription className="text-slate-400">
Legen Sie einen neuen Endkunden an. Diese Daten stehen sofort im Bestellwizard zur Auswahl.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
{([
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / KG / Einzelunternehmen' },
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '12345' },
{ label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' },
] as const).map(({ label, key, span, placeholder }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>
<Input
value={form[key]}
onChange={e => setForm(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
))}
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleSave}
disabled={saving || !form.company_name.trim()}
className="gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Kunden anlegen
</Button>
<Link href="/my-customers">
<Button variant="ghost">Abbrechen</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getEndCustomers } from '@/lib/actions/end-customers'
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export default async function MyCustomersPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const customers = await getEndCustomers()
// Anzahl Bestellungen pro Endkunde
const { data: orderCounts } = await supabase
.from('orders')
.select('end_customer_id')
.not('end_customer_id', 'is', null)
const countMap: Record<string, number> = {}
;(orderCounts ?? []).forEach(o => {
if (o.end_customer_id) {
countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 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">
<Building2 className="w-8 h-8 text-primary" />
Meine Kunden
</h1>
<p className="text-slate-400 text-sm mt-1">
Verwalten Sie Ihre Endkunden deren Daten werden für Bestellungen verwendet.
</p>
</div>
</div>
<Link href="/my-customers/new">
<Button className="gap-2">
<Plus className="w-4 h-4" /> Kunde hinzufügen
</Button>
</Link>
</div>
{/* Keine Kunden */}
{customers.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Building2 className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
<Link href="/my-customers/new">
<Button><Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen</Button>
</Link>
</CardContent>
</Card>
)}
{/* Kundenliste */}
{customers.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10 text-left">
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Unternehmen</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden sm:table-cell">Adresse</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden md:table-cell">Bestellungen</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{customers.map(customer => (
<tr key={customer.id} className="hover:bg-white/3 transition-colors">
<td className="py-4 pr-4">
<p className="font-semibold text-white">{customer.company_name}</p>
{(customer.first_name || customer.last_name) && (
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
</p>
)}
{customer.vat_id && (
<p className="text-xs text-slate-500">{customer.vat_id}</p>
)}
</td>
<td className="py-4 pr-4 hidden sm:table-cell">
<p className="text-sm text-slate-300">
{customer.street ?? ''}
</p>
<p className="text-sm text-slate-400">
{[customer.zip, customer.city].filter(Boolean).join(' ') || ''}
</p>
</td>
<td className="py-4 pr-4 hidden md:table-cell">
<Link href={`/my-orders?customer=${customer.id}`} className="group">
<span className="text-primary font-bold group-hover:underline">
{countMap[customer.id] ?? 0}
</span>
<span className="text-slate-500 text-xs ml-1">Bestellung(en)</span>
</Link>
</td>
<td className="py-4 pr-4">
{customer.is_anonymized ? (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">Aktiv</Badge>
)}
</td>
<td className="py-4 text-right">
{!customer.is_anonymized && (
<Link href={`/my-customers/${customer.id}`}>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white gap-1">
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
</Button>
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* DSGVO-Hinweis */}
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 text-xs text-amber-300/80 space-y-1">
<p className="font-semibold flex items-center gap-2"><AlertTriangle className="w-3.5 h-3.5" /> DSGVO-Hinweis</p>
<p>
Die hier gespeicherten Kundendaten unterliegen der DSGVO. Auf Wunsch Ihrer Endkunden können Sie deren Daten
über die Bearbeitungsmaske anonymisieren (Recht auf Vergessenwerden). Bestehende Bestellungen bleiben aus
steuerrechtlichen Gründen unverändert.
</p>
</div>
</div>
</div>
)
}

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

@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { getProducts, getCategories } from '@/lib/actions/products'
import { getEndCustomers } from '@/lib/actions/end-customers'
import { OrderWizard } from '@/components/order-wizard'
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
@@ -35,6 +36,7 @@ async function OrderDataWrapper() {
const products = await getProducts()
const categories = await getCategories()
const endCustomers = await getEndCustomers()
// Fetch profile if exists
const { data: profile } = await supabase
@@ -43,5 +45,5 @@ async function OrderDataWrapper() {
.eq('id', user.id)
.single()
return <OrderWizard products={products} categories={categories} initialProfile={profile} />
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
}

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

@@ -5,17 +5,29 @@ import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
export default function Home() {
return (
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50">
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
</div>
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50">
<Link className="flex items-center justify-center gap-2" href="#">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Rocket className="h-5 w-5 text-white" />
</div>
<span className="font-bold text-xl tracking-tighter">CloudShop</span>
<span className="font-bold text-xl tracking-tighter">CASPOS Store</span>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
<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-customers">
Meine Kunden
</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>
@@ -30,25 +42,25 @@ export default function Home() {
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-primary/10 px-3 py-1 text-sm text-primary font-medium border border-primary/20 mb-4">
Version 2.0 ist da
<div className="inline-block rounded-full bg-amber-500/10 px-3 py-1 text-sm text-amber-400 font-medium border border-amber-500/20 mb-4">
Demo-Umgebung für Testbestellungen
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
Die Zukunft der <span className="text-gradient">Software-Bestellung</span>
CASPOS <span className="text-gradient">Software-Bestellung</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
Modulare Lösungen, blitzschnelles Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
</p>
</div>
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
Jetzt konfigurieren <ArrowRight className="ml-2 h-5 w-5" />
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/admin/products">
<Link href="/contact">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
Admin Bereich
Contact Us
</Button>
</Link>
</div>
@@ -63,29 +75,29 @@ export default function Home() {
<div className="p-3 bg-primary/10 rounded-xl">
<Cpu className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Modulare Architektur</h3>
<p className="text-slate-400">Wählen Sie genau die Module, die Sie benötigen. Keine unnötigen Kosten für Features, die Sie nicht nutzen.</p>
<h3 className="text-xl font-bold">Handel</h3>
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Shield className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Maximale Sicherheit</h3>
<p className="text-slate-400">Enterprise-grade Security mit Supabase Auth und granularen Row Level Security Policies.</p>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-400">Egal ob mobiles Ordern, einer Küchen-Anzeige oder einen Pick-Up-Bord. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Sofort Einsatzbereit</h3>
<p className="text-slate-400">Nach der Bestellung wird Ihre Instanz automatisch provisioniert und ist in Minuten online.</p>
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
</div>
</div>
</div>
</section>
</main>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
<p className="text-xs text-slate-500">© 2026 CloudShop GmbH. Alle Rechte vorbehalten.</p>
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>

View File

@@ -23,6 +23,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { createCategory, updateCategory } from '@/lib/actions/products'
import { Category } from '@/lib/types'
import {
@@ -32,12 +33,15 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Cloud, Utensils, HardDrive, Package } from 'lucide-react'
const categorySchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
icon: z.string().default('Package'),
sort_order: z.coerce.number().int().min(0).default(0),
is_required: z.boolean().default(true),
})
type CategoryFormValues = z.infer<typeof categorySchema>
@@ -46,7 +50,7 @@ export function CategoryDialog({
children,
category
}: {
children: React.ReactNode,
children?: React.ReactNode,
category?: Category
}) {
const [open, setOpen] = useState(false)
@@ -57,6 +61,8 @@ export function CategoryDialog({
name: category?.name || '',
description: category?.description || '',
icon: category?.icon || 'Package',
sort_order: category?.sort_order ?? 0,
is_required: category?.is_required ?? true,
},
})
@@ -83,71 +89,124 @@ export function CategoryDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogTrigger asChild>
{children || (
<Button className="bg-primary hover:bg-primary/90">
Kategorie hinzufügen
</Button>
)}
</DialogTrigger>
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
<DialogHeader>
<DialogTitle>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
<DialogDescription>
<DialogDescription className="text-slate-300">
Definieren Sie eine Kategorie für Ihre Produkte.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icon</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue placeholder="Icon wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{icons.map(({ name, Icon }) => (
<SelectItem key={name} value={name}>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 flex-1 overflow-hidden flex flex-col min-h-0">
<ScrollArea className="max-h-[60vh] pr-4">
<div className="space-y-4 py-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Name</FormLabel>
<FormControl>
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Icon</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Icon wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{icons.map(({ name, Icon }) => (
<SelectItem key={name} value={name} className="text-white hover:bg-white/10">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Reihenfolge im Bestellvorgang</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="0 = zuerst"
className="bg-white/5 border-white/10 text-white"
{...field}
/>
</FormControl>
<FormMessage />
<p className="text-xs text-slate-400">Kleinere Zahl = weiter oben im Bestellformular angezeigt.</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_required"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-white/10 bg-white/5 p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-white font-semibold cursor-pointer">
Pflichtauswahl im Bestellvorgang
</FormLabel>
<p className="text-xs text-slate-400">
Wenn aktiv, muss der Kunde aus dieser Kategorie einen Artikel wählen.
Wenn deaktiviert, ist die Kategorie optional.
</p>
</div>
</FormItem>
)}
/>
</div>
</ScrollArea>
<DialogFooter className="pt-4">
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
{category ? 'Aktualisieren' : 'Erstellen'}
</Button>

View File

@@ -10,8 +10,9 @@ import {
TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package } from 'lucide-react'
import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { deleteCategory } from '@/lib/actions/products'
import { useState } from 'react'
import { CategoryDialog } from './category-dialog'
@@ -39,7 +40,7 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Kategorienübersicht</CardTitle>
<CardDescription>
<CardDescription className="text-slate-400">
Verwalten Sie die Hauptkategorien für Ihre Produkte.
</CardDescription>
</CardHeader>
@@ -47,10 +48,16 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-muted-foreground">Icon</TableHead>
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Beschreibung</TableHead>
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead>
<TableHead className="text-white font-bold w-10">Icon</TableHead>
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Beschreibung</TableHead>
<TableHead className="text-white font-bold w-28">
<span className="flex items-center gap-1">
<ArrowUpDown className="w-3 h-3" /> Reihenfolge
</span>
</TableHead>
<TableHead className="text-white font-bold w-24">Typ</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -61,8 +68,20 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
{getIcon(category.icon)}
</div>
</TableCell>
<TableCell className="font-medium">{category.name}</TableCell>
<TableCell className="text-muted-foreground">{category.description}</TableCell>
<TableCell className="font-medium text-white">{category.name}</TableCell>
<TableCell className="text-slate-300">{category.description}</TableCell>
<TableCell>
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-primary/20 text-primary text-sm font-bold">
{category.sort_order}
</span>
</TableCell>
<TableCell>
{category.is_required ? (
<Badge className="bg-green-500/20 text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
) : (
<Badge variant="outline" className="border-white/20 text-slate-400 whitespace-nowrap">Optional</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<CategoryDialog category={category}>
@@ -84,7 +103,7 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
))}
{categories.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-10 text-muted-foreground">
<TableCell colSpan={6} className="text-center py-10 text-slate-400">
Keine Kategorien gefunden.
</TableCell>
</TableRow>

View File

@@ -26,10 +26,10 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
import { createProduct } from '@/lib/actions/products'
import { createProduct, updateProduct } from '@/lib/actions/products'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Category } from '@/lib/types'
import { Category, Product } from '@/lib/types'
import {
Select,
SelectContent,
@@ -52,24 +52,31 @@ const productSchema = z.object({
description: z.string().optional(),
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
is_active: z.boolean().default(true),
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
modules: z.array(moduleSchema).default([]),
})
type ProductFormValues = z.infer<typeof productSchema>
export function CreateProductDialog({ categories }: { categories: Category[] }) {
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
const [open, setOpen] = useState(false)
const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any,
defaultValues: {
name: '',
description: '',
base_price: 0,
category_id: '',
is_active: true,
modules: [],
name: product?.name || '',
description: product?.description || '',
base_price: product?.base_price || 0,
category_id: product?.category_id || '',
billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({
name: m.name,
description: m.description || '',
price: m.price,
is_required: false,
requirements: m.requirements?.join(', ') || '',
exclusions: m.exclusions?.join(', ') || '',
})) || [],
},
})
@@ -86,32 +93,42 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
}))
await createProduct(productData as any, formattedModules as any)
if (product) {
await updateProduct(product.id, productData as any, formattedModules as any)
} else {
await createProduct(productData as any, formattedModules as any)
}
setOpen(false)
form.reset()
if (!product) form.reset()
} catch (error) {
console.error('Error creating product:', error)
console.error('Error saving product:', error)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
</Button>
{children || (
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Neues Produkt erstellen</DialogTitle>
<DialogDescription className="text-muted-foreground">
<DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
</DialogTitle>
<DialogDescription className="text-slate-300">
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 overflow-hidden flex flex-col">
<ScrollArea className="flex-1 pr-4">
<ScrollArea className="flex-1 max-h-[60vh] pr-4">
<div className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4">
<FormField
@@ -119,9 +136,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Produktname</FormLabel>
<FormLabel className="text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10" {...field} />
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -132,9 +149,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel>Basispreis ()</FormLabel>
<FormLabel className="text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/5 border-white/10" {...field} />
<Input type="number" step="0.01" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -147,16 +164,16 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<FormLabel className="text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue placeholder="Kategorie wählen" />
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Kategorie wählen" className="text-white" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
<SelectItem key={cat.id} value={cat.id} className="text-white">{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -165,14 +182,45 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
)}
/>
<FormField
control={form.control}
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Abrechnungsmodell</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-1">
{[
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => field.onChange(opt.value)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
field.value === opt.value
? 'border-primary bg-primary/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:bg-white/10'
}`}
>
<span className="text-lg">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10" {...field} />
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -216,9 +264,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Modulname</FormLabel>
<FormLabel className="text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10" {...field} />
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -229,9 +277,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel>Aufpreis ()</FormLabel>
<FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/10 border-white/10" {...field} />
<Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -245,9 +293,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name={`modules.${index}.requirements`}
render={({ field }) => (
<FormItem>
<FormLabel>Benötigt (IDs, kommagetrennt)</FormLabel>
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -258,9 +306,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
name={`modules.${index}.exclusions`}
render={({ field }) => (
<FormItem>
<FormLabel>Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -281,7 +329,7 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Erforderlich</FormLabel>
<FormLabel className="text-white">Erforderlich</FormLabel>
</div>
</FormItem>
)}
@@ -291,7 +339,7 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
))}
{fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-muted-foreground text-sm">
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
Noch keine Module hinzugefügt.
</div>
)}

View File

@@ -1,6 +1,6 @@
'use client'
import { Product } from '@/lib/types'
import { Product, Category } from '@/lib/types'
import {
Table,
TableBody,
@@ -15,8 +15,9 @@ import { Edit2, Trash2, Layers } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteProduct } from '@/lib/actions/products'
import { useState } from 'react'
import { CreateProductDialog } from './create-product-dialog'
export function ProductList({ initialProducts }: { initialProducts: Product[] }) {
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts)
const handleDelete = async (id: string) => {
@@ -30,7 +31,7 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription>
<CardDescription className="text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</CardHeader>
@@ -38,17 +39,17 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Basispreis</TableHead>
<TableHead className="text-muted-foreground">Module</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead>
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Basispreis</TableHead>
<TableHead className="text-white font-bold">Module</TableHead>
<TableHead className="text-white font-bold">Status</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium">
<TableCell className="font-medium text-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
@@ -57,27 +58,35 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
{product.category.name}
</Badge>
)}
{product.billing_interval === 'monthly' && (
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
)}
{product.billing_interval === 'one_time' && (
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-normal">{product.description}</p>
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" />
<span>{product.modules?.length || 0} Module</span>
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
</div>
</TableCell>
<TableCell>
<Badge variant={product.is_active ? "default" : "secondary"}>
{product.is_active ? "Aktiv" : "Inaktiv"}
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
<CreateProductDialog categories={categories} product={product}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CreateProductDialog>
<Button
variant="ghost"
size="icon"
@@ -92,7 +101,7 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-muted-foreground">
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
</TableCell>
</TableRow>

View 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>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { createUser } from '@/lib/actions/users'
import { Plus } from 'lucide-react'
const userSchema = z.object({
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen lang sein').optional(),
})
type UserFormValues = z.infer<typeof userSchema>
export function UserDialog() {
const [open, setOpen] = useState(false)
const router = useRouter()
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema) as any,
defaultValues: {
email: '',
password: '',
},
})
async function onSubmit(values: UserFormValues) {
try {
await createUser(values)
setOpen(false)
form.reset()
router.refresh()
} catch (error) {
console.error('Error creating user:', error)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" /> Benutzer neu anlegen
</Button>
</DialogTrigger>
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription className="text-slate-300">
Geben Sie die E-Mail und optional ein Passwort für den neuen Benutzer ein.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">E-Mail</FormLabel>
<FormControl>
<Input placeholder="user@example.com" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Passwort (optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="Passwort leer lassen für Zufall" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
Benutzer erstellen
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { UserX, ShieldAlert, Key, Trash2, Mail } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserStatus, resetPassword } from '@/lib/actions/users'
import { Badge } from '@/components/ui/badge'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
const router = useRouter()
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
await deleteUser(id)
router.refresh()
}
}
const handleToggleBan = async (id: string, isBanned: boolean) => {
await updateUserStatus(id, !isBanned)
router.refresh()
}
const handleResetPassword = async (email: string) => {
try {
await resetPassword(email)
alert('Password-Reset Link generiert (siehe Mailpit/Supabase Log)')
} catch (error) {
alert('Fehler beim Passwort-Reset')
}
}
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Benutzerverwaltung</CardTitle>
<CardDescription className="text-slate-400">
Hier können Sie Benutzer neu anlegen, sperren oder Passwörter zurücksetzen.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-slate-200">E-Mail</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{initialUsers.map((user) => {
const isBanned = !!user.banned_until
return (
<TableRow key={user.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-white">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-500" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
{isBanned ? "Gesperrt" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="text-slate-400 text-sm">
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
title="Passwort zurücksetzen"
onClick={() => handleResetPassword(user.email)}
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isBanned ? 'text-green-500 hover:text-green-400 hover:bg-green-500/10' : 'text-orange-500 hover:text-orange-400 hover:bg-orange-500/10'}`}
title={isBanned ? "Entsperren" : "Sperren"}
onClick={() => handleToggleBan(user.id, isBanned)}
>
{isBanned ? <ShieldAlert className="h-4 w-4" /> : <UserX className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
title="Löschen"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
)
}

View File

@@ -1,8 +1,9 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Product, ProductModule, Profile } from '@/lib/types'
import { Product, ProductModule, Profile, EndCustomer } from '@/lib/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -10,108 +11,201 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, CreditCard, ShieldCheck, Cloud, Utensils, HardDrive, Package } from 'lucide-react'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2 } from 'lucide-react'
import { submitOrder } from '@/lib/actions/orders'
import { createEndCustomer } from '@/lib/actions/end-customers'
import { Category } from '@/lib/types'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
export function OrderWizard({ products, categories, initialProfile }: { products: Product[], categories: Category[], initialProfile: Profile | null }) {
// ─── Types ────────────────────────────────────────────────────────────────────
type CategorySelection = {
productId: string | null // selected product in this category
moduleIds: string[] // selected module IDs for this product
}
// ─── Billing interval helpers ─────────────────────────────────────────────────
function billingLabel(interval?: string) {
if (interval === 'one_time') return 'einmalig'
return '/ Monat'
}
function billingBadgeClass(interval?: string) {
if (interval === 'one_time') return 'bg-slate-500/20 text-slate-300 border-slate-500/30'
return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
}
// ─── Icon helper ──────────────────────────────────────────────────────────────
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const Icon = icon === 'Cloud' ? Cloud : icon === 'Utensils' ? Utensils : icon === 'HardDrive' ? HardDrive : Package
return <Icon className={className} />
}
// ─── Module toggle logic ───────────────────────────────────────────────────────
function toggleModuleInList(
modules: ProductModule[],
currentIds: string[],
toggleId: string
): string[] {
const isSelected = currentIds.includes(toggleId)
if (isSelected) {
const next = currentIds.filter(id => id !== toggleId)
// cascade: remove modules that required this one
return next.filter(mId => {
const m = modules.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
}).filter(id => id !== toggleId)
} else {
const module = modules.find(m => m.id === toggleId)
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
return [...currentIds, toggleId]
}
}
function isModuleDisabled(module: ProductModule, selectedModules: string[]): boolean {
const requirementsMet = !module.requirements?.length ||
module.requirements.every(reqId => selectedModules.includes(reqId))
const isExcluded = module.exclusions?.some(exId => selectedModules.includes(exId))
return !requirementsMet || !!isExcluded
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
export function OrderWizard({
products,
categories,
initialProfile,
initialEndCustomers,
}: {
products: Product[]
categories: Category[]
initialProfile: Profile | null
initialEndCustomers: EndCustomer[]
}) {
const router = useRouter()
const [step, setStep] = useState(1)
const [activeCategory, setActiveCategory] = useState<string>(categories[0]?.id || 'all')
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
company_name: '',
vat_id: '',
first_name: '',
last_name: '',
address: '',
city: '',
zip_code: ''
const [isSubmitting, setIsSubmitting] = useState(false)
// Partner-Profil (Fallback)
const [customerData] = useState<Partial<Profile>>(initialProfile || {})
// Endkunden-State
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null
)
const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null
// Modus: 'select' = Bestandskunde wählen | 'create' = Neuen anlegen
const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select')
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const [newCustomerForm, setNewCustomerForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
})
const filteredProducts = useMemo(() => {
if (activeCategory === 'all') return products
return products.filter(p => p.category_id === activeCategory)
}, [products, activeCategory])
const [selectedProduct, setSelectedProduct] = useState<Product | null>(filteredProducts[0] || null)
const [selectedModules, setSelectedModules] = useState<string[]>([])
const totalPrice = useMemo(() => {
if (!selectedProduct) return 0
let total = Number(selectedProduct.base_price)
selectedModules.forEach(modId => {
const mod = selectedProduct.modules?.find(m => m.id === modId)
if (mod) total += Number(mod.price)
// Per-category selection map: categoryId -> { productId, moduleIds }
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const init: Record<string, CategorySelection> = {}
categories.forEach(cat => {
const first = products.find(p => p.category_id === cat.id)
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
})
return init
})
// Only REQUIRED categories must have a product selected
const allCategoriesFilled = useMemo(
() => categories.filter(cat => cat.is_required).every(cat => !!selections[cat.id]?.productId),
[categories, selections]
)
// Total price across all selections
const totalPrice = useMemo(() => {
let total = 0
for (const cat of categories) {
const sel = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find(p => p.id === sel.productId)
if (!prod) continue
total += Number(prod.base_price)
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) total += Number(mod.price)
})
}
return total
}, [selectedProduct, selectedModules])
}, [selections, products, categories])
// Helper: update product selection for a category (resets modules)
function selectProduct(catId: string, productId: string) {
setSelections(prev => ({
...prev,
[catId]: { productId, moduleIds: [] },
}))
}
// Helper: toggle module for a category's selected product
function toggleModule(catId: string, moduleId: string) {
setSelections(prev => {
const sel = prev[catId]
if (!sel?.productId) return prev
const prod = products.find(p => p.id === sel.productId)
const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId)
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
})
}
const nextStep = () => setStep(s => s + 1)
const prevStep = () => setStep(s => s - 1)
const toggleModule = (id: string) => {
const module = selectedProduct?.modules?.find(m => m.id === id)
if (!module) return
setSelectedModules(prev => {
const isSelected = prev.includes(id)
if (isSelected) {
// When deselecting, we might need to deselect others that depend on this one
const newSelection = prev.filter(m => m !== id)
return newSelection.filter(mId => {
const m = selectedProduct?.modules?.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => newSelection.includes(reqId) || reqId === id)
}).filter(mId => mId !== id)
} else {
// When selecting, check exclusions
if (module.exclusions && module.exclusions.some(exId => prev.includes(exId))) {
return prev // Should be disabled anyway, but safety check
}
return [...prev, id]
}
})
}
const isModuleDisabled = (module: ProductModule) => {
// 1. Check requirements: all required modules must be selected
const requirementsMet = !module.requirements || module.requirements.length === 0 ||
module.requirements.every(reqId => selectedModules.includes(reqId))
// 2. Check exclusions: no excluded module must be selected
const isExcluded = module.exclusions && module.exclusions.some(exId => selectedModules.includes(exId))
return !requirementsMet || isExcluded
}
const handleSubmit = async () => {
if (!selectedProduct) return
// Neuen Endkunden inline anlegen
const handleCreateCustomer = async () => {
if (!newCustomerForm.company_name.trim()) return
setIsCreatingCustomer(true)
try {
await submitOrder({
product_id: selectedProduct.id,
selected_modules: selectedModules,
total_amount: totalPrice,
customer_details: customerData
})
alert('Bestellung erfolgreich aufgegeben!')
window.location.href = '/'
} catch (error) {
console.error(error)
alert('Fehler bei der Bestellung.')
const created = await createEndCustomer(newCustomerForm)
setEndCustomers(prev => [...prev, created])
setSelectedEndCustomerId(created.id)
setCustomerMode('select')
setNewCustomerForm({ company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '' })
} catch (e) {
alert('Fehler beim Anlegen des Kunden.')
} finally {
setIsCreatingCustomer(false)
}
}
const handleSubmit = async () => {
if (isSubmitting) return // Doppelklick-Guard
setIsSubmitting(true)
try {
const order = await submitOrder({
selections,
products,
categories,
customerProfile: customerData,
endCustomerId: selectedEndCustomerId,
endCustomer: selectedEndCustomer,
})
router.push(`/order/success?id=${order.id}`)
} catch (error) {
console.error(error)
alert('Fehler bei der Bestellung. Bitte versuchen Sie es erneut.')
setIsSubmitting(false) // Nur bei Fehler wieder entsperren
}
}
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */}
<div className="flex justify-between mb-12 relative">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
{[1, 2, 3].map((s) => (
{[1, 2, 3].map(s => (
<div
key={s}
className={`relative z-10 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
step >= s ? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(var(--primary),0.5)]' : 'bg-slate-800 text-slate-400'
step >= s
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
: 'bg-slate-800 text-slate-400'
}`}
>
{step > s ? <Check className="w-6 h-6" /> : s}
@@ -120,6 +214,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
</div>
<AnimatePresence mode="wait">
{/* ───── Step 1: Product Selection per Category ───── */}
{step === 1 && (
<motion.div
key="step1"
@@ -129,152 +224,226 @@ export function OrderWizard({ products, categories, initialProfile }: { products
className="space-y-6"
>
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6">
{/* Left: Category sections */}
<div className="md:col-span-2 space-y-8">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2">
<ShoppingCart className="w-6 h-6 text-primary" />
Software wählen
</CardTitle>
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Category Tabs */}
<div className="space-y-4">
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Kategorie</Label>
<Tabs value={activeCategory} onValueChange={(val) => {
setActiveCategory(val)
// Auto-select first product in new category
const firstInCat = products.find(p => val === 'all' || p.category_id === val)
if (firstInCat) {
setSelectedProduct(firstInCat)
setSelectedModules([])
}
}}>
<TabsList className="bg-white/5 border border-white/10 p-1 h-auto grid grid-cols-2 md:grid-cols-4 gap-2">
<TabsTrigger value="all" className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
<Package className="w-4 h-4" /> Alle
</TabsTrigger>
{categories.map(cat => {
const Icon = cat.icon === 'Cloud' ? Cloud : cat.icon === 'Utensils' ? Utensils : cat.icon === 'HardDrive' ? HardDrive : Package
return (
<TabsTrigger key={cat.id} value={cat.id} className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
<Icon className="w-4 h-4" /> {cat.name}
</TabsTrigger>
)
})}
</TabsList>
</Tabs>
</div>
<CardContent className="space-y-8">
{categories.map((cat, idx) => {
const catProducts = products.filter(p => p.category_id === cat.id)
const sel = selections[cat.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
<Separator className="bg-white/10" />
return (
<div key={cat.id}>
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
<div className="space-y-4">
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Lösung</Label>
<RadioGroup
value={selectedProduct?.id}
onValueChange={(id) => {
const p = products.find(prod => prod.id === id)
if (p) {
setSelectedProduct(p)
setSelectedModules([]) // Reset modules when product changes
}
}}
className="grid gap-4"
>
{filteredProducts.map(product => (
<div key={product.id} className="relative">
<RadioGroupItem value={product.id} id={product.id} className="peer sr-only" />
<Label
htmlFor={product.id}
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<div className="flex justify-between w-full items-center">
<span className="font-bold text-lg">{product.name}</span>
{product.category && (
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary">
{product.category.name}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">{product.description}</span>
<span className="mt-2 text-primary font-semibold">
Ab {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}
</span>
</Label>
</div>
))}
</RadioGroup>
</div>
{selectedProduct && selectedProduct.modules && selectedProduct.modules.length > 0 && (
<div className="space-y-4 pt-4">
<Label className="text-lg font-semibold">Zusatzmodule</Label>
<div className="grid gap-3">
{selectedProduct.modules.map(module => (
<div
key={module.id}
className="flex items-center space-x-3 p-3 rounded-lg border border-white/5 bg-white/5 hover:bg-white/10 transition-colors"
>
<Checkbox
id={module.id}
checked={selectedModules.includes(module.id)}
onCheckedChange={() => toggleModule(module.id)}
disabled={isModuleDisabled(module)}
/>
<div className="flex-1">
<Label
htmlFor={module.id}
className={`font-medium cursor-pointer flex justify-between ${isModuleDisabled(module) ? 'opacity-50' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary">+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(module.price)}</span>
</Label>
<p className="text-xs text-muted-foreground">{module.description}</p>
{isModuleDisabled(module) && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements && module.requirements.some(reqId => !selectedModules.includes(reqId))
? "Benötigt weitere Module"
: "Nicht kombinierbar mit aktueller Auswahl"}
</p>
)}
</div>
{/* Category header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
</div>
))}
<div>
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
{cat.description && (
<p className="text-slate-400 text-xs">{cat.description}</p>
)}
</div>
{sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : cat.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-80">
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
Optional
</Badge>
)}
</div>
{catProducts.length === 0 ? (
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie vorhanden.
</p>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(cat.id, id)}
className="grid gap-3"
>
{catProducts.map(product => (
<div key={product.id} className="relative">
<RadioGroupItem
value={product.id}
id={`${cat.id}-${product.id}`}
className="peer sr-only"
/>
<Label
htmlFor={`${cat.id}-${product.id}`}
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-2">
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
))}
</RadioGroup>
)}
{/* Modules for selected product in this category */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
{selectedProduct.modules.map(module => {
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = sel?.moduleIds.includes(module.id) ?? false
return (
<div
key={module.id}
className={`flex items-start space-x-3 p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
>
<Checkbox
id={`mod-${cat.id}-${module.id}`}
checked={checked}
onCheckedChange={() => toggleModule(cat.id, module.id)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`mod-${cat.id}-${module.id}`}
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary font-bold">
+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400">{module.description}</p>
)}
{disabled && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements?.some(
reqId => !sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
)
})}
</CardContent>
</Card>
</div>
{/* Right: Summary */}
<div className="space-y-6">
<Card className="glass-dark border-primary/20 sticky top-6">
<CardHeader>
<CardTitle>Zusammenfassung</CardTitle>
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Basispreis:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(selectedProduct?.base_price || 0)}</span>
</div>
{selectedModules.map(modId => {
const mod = selectedProduct?.modules?.find(m => m.id === modId)
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className="w-3 h-3 inline-block" />
)}
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
return (
<div key={modId} className="flex justify-between text-sm">
<span className="text-muted-foreground">{mod?.name}:</span>
<span>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.price || 0)}</span>
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
<div className="flex justify-between text-sm pl-2">
<span className="text-white">{prod.name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-4">
<span className="text-slate-300">+ {mod.name}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
</span>
</div>
) : null
})}
</div>
)
})}
<Separator className="bg-white/10" />
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
</span>
</div>
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p>
)}
</CardContent>
<CardFooter>
<Button className="w-full h-12 text-lg" onClick={nextStep} disabled={!selectedProduct}>
<Button
className="w-full h-12 text-lg"
onClick={nextStep}
disabled={!allCategoriesFilled}
>
Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button>
</CardFooter>
@@ -284,6 +453,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
</motion.div>
)}
{/* ───── Step 2: Endkunde wählen / anlegen ───── */}
{step === 2 && (
<motion.div
key="step2"
@@ -296,75 +466,127 @@ export function OrderWizard({ products, categories, initialProfile }: { products
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2">
<User className="w-6 h-6 text-primary" />
Kundendaten
Für wen bestellen Sie?
</CardTitle>
<CardDescription>Wir benötigen Ihre Daten für die Rechnungsstellung.</CardDescription>
<CardDescription className="text-slate-300">
Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an.
</CardDescription>
</CardHeader>
<CardContent className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Firmenname</Label>
<Input
value={customerData.company_name || ''}
onChange={(e) => setCustomerData({...customerData, company_name: e.target.value})}
placeholder="GmbH / Einzelunternehmen"
className="bg-white/5 border-white/10"
/>
</div>
<div className="space-y-2">
<Label>USt-IdNr.</Label>
<Input
value={customerData.vat_id || ''}
onChange={(e) => setCustomerData({...customerData, vat_id: e.target.value})}
placeholder="DE123456789"
className="bg-white/5 border-white/10"
/>
</div>
<div className="space-y-2">
<Label>Vorname</Label>
<Input
value={customerData.first_name || ''}
onChange={(e) => setCustomerData({...customerData, first_name: e.target.value})}
className="bg-white/5 border-white/10"
/>
</div>
<div className="space-y-2">
<Label>Nachname</Label>
<Input
value={customerData.last_name || ''}
onChange={(e) => setCustomerData({...customerData, last_name: e.target.value})}
className="bg-white/5 border-white/10"
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Straße & Hausnummer</Label>
<Input
value={customerData.address || ''}
onChange={(e) => setCustomerData({...customerData, address: e.target.value})}
className="bg-white/5 border-white/10"
/>
</div>
<div className="space-y-2">
<Label>Postleitzahl</Label>
<Input
value={customerData.zip_code || ''}
onChange={(e) => setCustomerData({...customerData, zip_code: e.target.value})}
className="bg-white/5 border-white/10"
/>
</div>
<div className="space-y-2">
<Label>Ort</Label>
<Input
value={customerData.city || ''}
onChange={(e) => setCustomerData({...customerData, city: e.target.value})}
className="bg-white/5 border-white/10"
/>
<CardContent className="space-y-6">
{/* Modus-Toggle */}
<div className="flex gap-2">
<Button
variant={customerMode === 'select' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('select')}
className="gap-2"
>
<Building2 className="w-4 h-4" /> Bestandskunde wählen
</Button>
<Button
variant={customerMode === 'create' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('create')}
className="gap-2"
>
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
</Button>
</div>
{/* Modus A: Bestandskunde wählen */}
{customerMode === 'select' && (
<div className="space-y-4">
{endCustomers.length === 0 ? (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10">
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
</Button>
</div>
) : (
<div className="space-y-2">
{endCustomers.filter(c => !c.is_anonymized).map(customer => (
<label
key={customer.id}
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${
selectedEndCustomerId === customer.id
? 'border-primary bg-primary/5'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<input
type="radio"
name="end_customer"
value={customer.id}
checked={selectedEndCustomerId === customer.id}
onChange={() => setSelectedEndCustomerId(customer.id)}
className="sr-only"
/>
<div className="flex-1">
<p className="font-semibold text-white">{customer.company_name}</p>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
{customer.first_name || customer.last_name ? ' · ' : ''}
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
</p>
</div>
{selectedEndCustomerId === customer.id && (
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
)}
</label>
))}
</div>
)}
</div>
)}
{/* Modus B: Neuen Kunden anlegen */}
{customerMode === 'create' && (
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
{([
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
] as const).map(({ label, key, span, placeholder }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>
<Input
value={newCustomerForm[key]}
onChange={e => setNewCustomerForm(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
))}
<div className="md:col-span-2 flex gap-2">
<Button
onClick={handleCreateCustomer}
disabled={isCreatingCustomer || !newCustomerForm.company_name.trim()}
className="gap-2"
>
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Kunden speichern & auswählen
</Button>
<Button variant="ghost" onClick={() => setCustomerMode('select')}>
Abbrechen
</Button>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
<Button variant="ghost" onClick={prevStep}>
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
</Button>
<Button onClick={nextStep}>
<Button
onClick={nextStep}
disabled={customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId}
>
Prüfung & Abschluss <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
@@ -372,6 +594,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
</motion.div>
)}
{/* ───── Step 3: Review & Submit ───── */}
{step === 3 && (
<motion.div
key="step3"
@@ -385,30 +608,89 @@ export function OrderWizard({ products, categories, initialProfile }: { products
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-3xl">Bestellung prüfen</CardTitle>
<CardDescription>Fast fertig! Bitte überprüfen Sie Ihre Auswahl.</CardDescription>
<CardTitle className="text-3xl text-white">Bestellung prüfen</CardTitle>
<CardDescription className="text-slate-300">
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 text-left">
<div className="p-4 rounded-lg bg-white/5 space-y-4">
<div className="flex justify-between font-bold text-lg">
<span>{selectedProduct?.name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat</span>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>Inklusive: {selectedModules.length > 0 ? selectedModules.map(id => selectedProduct?.modules?.find(m => m.id === id)?.name).join(', ') : 'Keine Zusatzmodule'}</p>
<Separator className="bg-white/10 my-2" />
<p>{customerData.company_name}</p>
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return null
const catTotal =
Number(prod.base_price) +
sel.moduleIds.reduce((acc, mId) => {
const mod = prod.modules?.find(m => m.id === mId)
return acc + Number(mod?.price ?? 0)
}, 0)
return (
<div key={cat.id}>
<div className="flex justify-between font-bold text-base text-white">
<span className="flex items-center gap-2">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
{prod.name}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.length > 0 && (
<p className="text-sm text-slate-300 mt-1 pl-6">
Module: {sel.moduleIds
.map(id => prod.modules?.find(m => m.id === id)?.name)
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
)
})}
<Separator className="bg-white/10" />
<div className="text-sm text-slate-300 space-y-1">
{selectedEndCustomer ? (
<>
<p className="font-semibold text-white flex items-center gap-2">
<Building2 className="w-3.5 h-3.5 text-primary" />
{selectedEndCustomer.company_name}
</p>
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
</>
) : (
<>
<p className="font-semibold text-white">{customerData.company_name}</p>
<p>{customerData.first_name} {customerData.last_name}</p>
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
</>
)}
</div>
</div>
<p className="text-xs text-muted-foreground italic">
Mit dem Klick auf "Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die Datenschutzerklärung.
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
</span>
</div>
<p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
Datenschutzerklärung.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90" onClick={handleSubmit}>
Kostenpflichtig bestellen
<Button
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Bestellung wird verarbeitet...</>
) : (
'Kostenpflichtig bestellen'
)}
</Button>
<Button variant="ghost" onClick={prevStep} className="w-full">
Noch etwas ändern

View File

@@ -0,0 +1,145 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import type { EndCustomer } from '@/lib/types'
// ─── Typen ───────────────────────────────────────────────────────────────────
export type EndCustomerFormData = {
company_name: string
vat_id?: string
first_name?: string
last_name?: string
street?: string
zip?: string
city?: string
}
// ─── Lesen ───────────────────────────────────────────────────────────────────
/**
* Holt alle (nicht anonymisierten) Endkunden des eingeloggten Partners.
* RLS stellt sicher, dass nur eigene Kunden zurückgegeben werden.
*/
export async function getEndCustomers(): Promise<EndCustomer[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('end_customers')
.select('*')
.order('company_name', { ascending: true })
if (error) throw error
return data as EndCustomer[]
}
/**
* Holt einen einzelnen Endkunden (Eigentümerprüfung via RLS).
*/
export async function getEndCustomer(id: string): Promise<EndCustomer | null> {
const supabase = await createClient()
const { data, error } = await supabase
.from('end_customers')
.select('*')
.eq('id', id)
.single()
if (error) return null
return data as EndCustomer
}
// ─── Erstellen ────────────────────────────────────────────────────────────────
/**
* Legt einen neuen Endkunden für den eingeloggten Partner an.
* partner_id wird serverseitig aus der Session gesetzt (kein Client-Trust).
*/
export async function createEndCustomer(formData: EndCustomerFormData): Promise<EndCustomer> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { data, error } = await supabase
.from('end_customers')
.insert([{
partner_id: user.id,
company_name: formData.company_name,
vat_id: formData.vat_id || null,
first_name: formData.first_name || null,
last_name: formData.last_name || null,
street: formData.street || null,
zip: formData.zip || null,
city: formData.city || null,
}])
.select()
.single()
if (error) throw error
revalidatePath('/my-customers')
return data as EndCustomer
}
// ─── Aktualisieren ────────────────────────────────────────────────────────────
/**
* Aktualisiert Adressdaten eines Endkunden.
* RLS verhindert Zugriff auf fremde Kunden.
*/
export async function updateEndCustomer(id: string, formData: EndCustomerFormData): Promise<void> {
const supabase = await createClient()
const { error } = await supabase
.from('end_customers')
.update({
company_name: formData.company_name,
vat_id: formData.vat_id || null,
first_name: formData.first_name || null,
last_name: formData.last_name || null,
street: formData.street || null,
zip: formData.zip || null,
city: formData.city || null,
})
.eq('id', id)
if (error) throw error
revalidatePath('/my-customers')
revalidatePath(`/my-customers/${id}`)
}
// ─── DSGVO: Anonymisieren ─────────────────────────────────────────────────────
/**
* Anonymisiert einen Endkunden gemäß DSGVO „Recht auf Vergessenwerden".
*
* - Überschreibt alle personenbezogenen Felder mit [GELÖSCHT]
* - Setzt is_anonymized = true (verhindert weitere Bearbeitung)
* - LÖSCHT NICHT die Zeile (FK in orders bleibt erhalten)
* - Bestell-Snapshots in orders.customer_data werden NICHT verändert
* (steuerrechtliche Aufbewahrungspflicht)
*/
export async function anonymizeEndCustomer(id: string): Promise<void> {
const supabase = await createClient()
const { error } = await supabase
.from('end_customers')
.update({
company_name: '[GELÖSCHT]',
vat_id: null,
first_name: '[GELÖSCHT]',
last_name: null,
street: null,
zip: null,
city: null,
is_anonymized: true,
})
.eq('id', id)
.eq('is_anonymized', false) // Keine doppelte Anonymisierung
if (error) throw error
revalidatePath('/my-customers')
revalidatePath(`/my-customers/${id}`)
}

View File

@@ -5,89 +5,146 @@ import { revalidatePath } from 'next/cache'
import { InvoicePDF } from '@/components/invoice-pdf'
import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react'
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
export async function submitOrder(orderData: {
product_id: string;
selected_modules: string[];
total_amount: number;
customer_details: any;
}) {
// ─── 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 ──────────────────────────────────────────────────────────────
/**
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
* 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: {
selections: WizardSelections
products: Product[]
categories: Category[]
customerProfile: Partial<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
}): Promise<Order> {
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// 1. Fetch Product and Module Details for Invoice
const { data: product } = await supabase
.from('products')
// 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
// 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('id', orderData.product_id)
.single()
.eq('user_id', user.id)
.eq('order_hash', orderHash)
.gte('created_at', thirtySecondsAgo)
.maybeSingle()
const { data: modules } = await supabase
.from('product_modules')
.select('*')
.in('id', orderData.selected_modules)
if (existingOrder) {
console.warn('[submitOrder] Duplicate order detected, returning existing order:', existingOrder.order_number)
return existingOrder as Order
}
if (!product) throw new Error('Product not found')
// 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.')
// 2. Create the Order in DB
// 4. Bestellung in DB schreiben
const { data: order, error: orderError } = await supabase
.from('orders')
.insert([{
user_id: user.id,
total_price: orderData.total_amount, // Matched column name in initial_schema.sql
order_data: {
product_id: orderData.product_id,
modules: orderData.selected_modules
},
customer_data: orderData.customer_details,
status: 'pending'
order_number: orderNumber,
order_hash: orderHash,
end_customer_id: endCustomerId ?? null,
total_price: orderSnapshot.total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
status: 'pending',
}])
.select()
.single()
if (orderError) throw orderError
// 3. Generate PDF
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order: order,
product: product,
modules: modules || [],
customer: orderData.customer_details
order,
customer: customerSnapshot,
orderSnapshot,
})
)
// 4. Upload to Supabase Storage
// 6. PDF in Supabase Storage hochladen
const fileName = `invoice_${order.id}.pdf`
const { data: uploadData, error: uploadError } = await supabase
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, {
contentType: 'application/pdf',
upsert: true
})
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
if (uploadError) {
console.error('PDF Upload Error:', uploadError)
} else {
// 5. Update Order with PDF URL
const { data: publicUrlData } = supabase
.storage
.from('invoices')
.getPublicUrl(fileName)
await supabase
.from('orders')
.update({ pdf_url: publicUrlData.publicUrl })
.eq('id', order.id)
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
order.pdf_url = publicUrlData.publicUrl
}
} catch (pdfError) {
console.error('PDF Generation Error:', pdfError)
}
revalidatePath('/protected')
return order
revalidatePath('/my-orders')
return order as Order
}

View File

@@ -2,7 +2,7 @@
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { Product, ProductModule } from '../types'
import { Category, Product, ProductModule } from '../types'
export async function getProducts() {
const supabase = await createClient()
@@ -20,7 +20,7 @@ export async function getCategories() {
const { data, error } = await supabase
.from('categories')
.select('*')
.order('name', { ascending: true })
.order('sort_order', { ascending: true })
if (error) throw error
return data as Category[]
@@ -107,9 +107,8 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modules.length > 0) {
const modulesWithId = modules.map(m => ({
name: m.name,
description: m.description,
price: m.additional_price, // Match schema column name 'price'
is_required: m.is_required,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
product_id: id

50
shop/lib/actions/users.ts Normal file
View File

@@ -0,0 +1,50 @@
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
export async function getUsers() {
const admin = createAdminClient()
const { data: { users }, error } = await admin.auth.admin.listUsers()
if (error) throw error
return users
}
export async function createUser(data: { email: string; password?: string; email_confirm?: boolean }) {
const admin = createAdminClient()
const { data: { user }, error } = await admin.auth.admin.createUser({
email: data.email,
password: data.password || Math.random().toString(36).slice(-10),
email_confirm: data.email_confirm ?? true,
})
if (error) throw error
revalidatePath('/admin/users')
return user
}
export async function deleteUser(id: string) {
const admin = createAdminClient()
const { error } = await admin.auth.admin.deleteUser(id)
if (error) throw error
revalidatePath('/admin/users')
}
export async function updateUserStatus(id: string, ban: boolean) {
const admin = createAdminClient()
const { error } = await admin.auth.admin.updateUserById(id, {
ban_duration: ban ? '876000h' : '0h', // 100 years or none
})
if (error) throw error
revalidatePath('/admin/users')
}
export async function resetPassword(email: string) {
const admin = createAdminClient()
// Generate a reset link or send email
const { error } = await admin.auth.admin.generateLink({
type: 'recovery',
email,
})
if (error) throw error
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
}

View File

@@ -0,0 +1,261 @@
/**
* license-transform.ts
*
* Transformationsschicht: Wandelt den Wizard-Zustand in strukturierte
* Datenstrukturen für DB-Speicherung (OrderSnapshot) und Lizenzserver (LicenseBundle) um.
*/
import type {
Category,
CategorySelection,
CustomerSnapshot,
EndCustomer,
LicenseBundle,
LicenseOption,
OrderItem,
OrderSnapshot,
Product,
Profile,
WizardSelections,
} from '@/lib/types'
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
/**
* Wandelt einen Produktnamen in einen stabilen, URL-sicheren Slug um.
* Beispiel: "CASPOS Basic" → "caspos_basic"
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '')
}
/**
* Baut einen validen CustomerSnapshot aus einem Profil oder Formulardaten.
*
* Wenn ein Endkunde übergeben wird, werden DESSEN Daten eingefroren (für die Rechnung).
* Ohne Endkunden-Übergabe: Partner-Profil als Fallback (Abwärtskompatibilität).
* Fehlende Pflichtfelder werden mit leerem String aufgefüllt (Validierung im Wizard).
*/
export function buildCustomerSnapshot(
profile: Partial<Profile>,
endCustomer?: EndCustomer | null
): CustomerSnapshot {
if (endCustomer) {
return {
schema_version: 1,
end_customer_id: endCustomer.id,
company_name: endCustomer.company_name ?? '',
vat_id: endCustomer.vat_id ?? '',
first_name: endCustomer.first_name ?? '',
last_name: endCustomer.last_name ?? '',
address: endCustomer.street ?? '',
zip_code: endCustomer.zip ?? '',
city: endCustomer.city ?? '',
}
}
return {
schema_version: 1,
company_name: profile.company_name ?? '',
vat_id: profile.vat_id ?? '',
first_name: profile.first_name ?? '',
last_name: profile.last_name ?? '',
address: profile.address ?? '',
zip_code: profile.zip_code ?? '',
city: profile.city ?? '',
...(profile.email ? { email: profile.email } : {}),
}
}
// ─── OrderSnapshot ────────────────────────────────────────────────────────────
/**
* Baut den unveränderlichen OrderSnapshot für die JSONB-Spalte `orders.order_data`.
*
* WICHTIG: Alle Namen und Preise werden hier eingefroren (Snapshot-Prinzip).
* Spätere Änderungen am Produktkatalog brechen keine historischen Bestellungen.
*/
export function buildOrderSnapshot(
selections: WizardSelections,
products: Product[],
categories: Category[]
): OrderSnapshot {
const items: OrderItem[] = []
let subtotal = 0
let dominantTaxRate = 19 // Fallback; wird durch das erste Produkt mit tax_rate überschrieben
for (const cat of categories) {
const sel: CategorySelection | undefined = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find((p) => p.id === sel.productId)
if (!prod) continue
dominantTaxRate = prod.tax_rate
const selectedModules = sel.moduleIds
.map((modId) => prod.modules?.find((m) => m.id === modId))
.filter((m): m is NonNullable<typeof m> => !!m)
.map((mod) => ({
module_id: mod.id,
module_name: mod.name,
price: mod.price,
}))
const moduleTotal = selectedModules.reduce((acc, m) => acc + m.price, 0)
const itemTotal = prod.base_price + moduleTotal
subtotal += itemTotal
items.push({
category_id: cat.id,
category_name: cat.name,
product_id: prod.id,
product_name: prod.name,
base_price: prod.base_price,
billing_interval: prod.billing_interval,
selected_modules: selectedModules,
item_total: itemTotal,
})
}
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
const total = Math.round((subtotal + taxAmount) * 100) / 100
// Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly'
const billingCycle = items.every((i) => i.billing_interval === 'one_time')
? 'one_time'
: 'monthly'
return {
schema_version: 1,
billing_cycle: billingCycle,
items,
subtotal,
tax_rate: dominantTaxRate,
tax_amount: taxAmount,
total,
}
}
// ─── LicenseBundle ────────────────────────────────────────────────────────────
/**
* Transformiert einen gespeicherten OrderSnapshot (oder den aktuellen Wizard-Zustand)
* in ein LicenseBundle für den Lizenzserver / das ERP-System.
*
* Jedes Produkt und jedes Modul wird auf einen stabilen `key` (Slug) gemappt,
* der als Identifier im Lizenzserver verwendet werden kann.
*/
export function transformToLicenseBundle(
selections: WizardSelections,
products: Product[],
categories: Category[],
customer: Partial<Profile>
): LicenseBundle {
const options: LicenseOption[] = []
let monthlyTotal = 0
let oneTimeTotal = 0
for (const cat of categories) {
const sel: CategorySelection | undefined = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find((p) => p.id === sel.productId)
if (!prod) continue
const prodKey = slugify(prod.name)
options.push({
key: prodKey,
label: prod.name,
active: true,
billing: prod.billing_interval,
price_net: prod.base_price,
})
if (prod.billing_interval === 'monthly') monthlyTotal += prod.base_price
else oneTimeTotal += prod.base_price
for (const modId of sel.moduleIds) {
const mod = prod.modules?.find((m) => m.id === modId)
if (!mod) continue
options.push({
key: `${prodKey}__${slugify(mod.name)}`,
label: mod.name,
active: true,
// Module erben das Billing-Intervall des übergeordneten Produkts
billing: prod.billing_interval,
price_net: mod.price,
})
if (prod.billing_interval === 'monthly') monthlyTotal += mod.price
else oneTimeTotal += mod.price
}
}
return {
customer_ref: customer.company_name ?? customer.last_name ?? 'unknown',
generated_at: new Date().toISOString(),
license_key_prefix: `CASP-${new Date().getFullYear()}-`,
options,
monthly_total_net: monthlyTotal,
one_time_total_net: oneTimeTotal,
}
}
/**
* Rekonstruiert ein LicenseBundle direkt aus einem gespeicherten OrderSnapshot
* (ohne Zugriff auf den Live-Katalog gut für historische Bestellungen).
*/
export function licenseBundleFromSnapshot(
snapshot: OrderSnapshot,
customerRef: string
): LicenseBundle {
const options: LicenseOption[] = []
let monthlyTotal = 0
let oneTimeTotal = 0
for (const item of snapshot.items) {
const prodKey = slugify(item.product_name)
options.push({
key: prodKey,
label: item.product_name,
active: true,
billing: item.billing_interval,
price_net: item.base_price,
})
if (item.billing_interval === 'monthly') monthlyTotal += item.base_price
else oneTimeTotal += item.base_price
for (const mod of item.selected_modules) {
options.push({
key: `${prodKey}__${slugify(mod.module_name)}`,
label: mod.module_name,
active: true,
billing: item.billing_interval,
price_net: mod.price,
})
if (item.billing_interval === 'monthly') monthlyTotal += mod.price
else oneTimeTotal += mod.price
}
}
return {
customer_ref: customerRef,
generated_at: new Date().toISOString(),
license_key_prefix: `CASP-${new Date().getFullYear()}-`,
options,
monthly_total_net: monthlyTotal,
one_time_total_net: oneTimeTotal,
}
}

View File

@@ -0,0 +1,16 @@
import { createClient } from '@supabase/supabase-js'
export function createAdminClient() {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
serviceRoleKey,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
)
}

View File

@@ -1,56 +1,164 @@
// ─── Katalog (gelesen aus Supabase) ──────────────────────────────────────────
export type BillingInterval = 'one_time' | 'monthly'
export type Product = {
id: string;
name: string;
description?: string | null | undefined;
base_price: number;
is_active: boolean;
created_at: string;
updated_at: string;
category_id?: string | null;
category?: Category | null;
modules?: ProductModule[];
};
id: string
name: string
description?: string | null
base_price: number
tax_rate: number
billing_interval: BillingInterval
category_id?: string | null
category?: Category | null
modules?: ProductModule[]
created_at: string
}
export type Category = {
id: string;
name: string;
description?: string | null;
icon?: string | null;
created_at: string;
};
id: string
name: string
description?: string | null
icon?: string | null
sort_order: number
is_required: boolean
created_at: string
}
export type ProductModule = {
id: string;
product_id: string;
name: string;
description?: string | null | undefined;
price: number;
is_required: boolean;
is_active?: boolean;
requirements: string[];
exclusions: string[];
created_at: string;
};
id: string
product_id: string
name: string
description?: string | null
price: number
requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
created_at: string
}
export type Profile = {
id: string;
company_name: string | null;
vat_id: string | null;
first_name: string | null;
last_name: string | null;
address: string | null;
city: string | null;
zip_code: string | null;
country: string;
updated_at: string;
};
id: string
company_name: string | null
vat_id: string | null
first_name: string | null
last_name: string | null
address: string | null
city: string | null
zip_code: string | null
country: string
email?: string | null
updated_at: string
}
// ─── Endkunde (verwaltet vom Partner) ────────────────────────────────────────
export type EndCustomer = {
id: string
partner_id: string
company_name: string
vat_id: string | null
first_name: string | null
last_name: string | null
street: string | null
zip: string | null
city: string | null
is_anonymized: boolean
created_at: string
}
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
export type CategorySelection = {
productId: string | null
moduleIds: string[]
}
/** key = category_id */
export type WizardSelections = Record<string, CategorySelection>
// ─── Versioned Snapshots (unveränderlich in JSONB gespeichert) ────────────────
/**
* Kundendaten zum Zeitpunkt der Bestellung.
* schema_version erlaubt spätere Migrationsfunktionen ohne DB-Änderung.
*/
export type CustomerSnapshot = {
schema_version: 1
/** ID des Endkunden zum Zeitpunkt der Bestellung (für Audit-Trail) */
end_customer_id?: string
company_name: string
vat_id: string
first_name: string
last_name: string
address: string
zip_code: string
city: string
email?: string
}
export type OrderModuleSnapshot = {
module_id: string
module_name: string
price: number
}
export type OrderItem = {
category_id: string
category_name: string
product_id: string
product_name: string
base_price: number
billing_interval: BillingInterval
selected_modules: OrderModuleSnapshot[]
item_total: number
}
/**
* Vollständiger Bestell-Snapshot wird in orders.order_data (JSONB) gespeichert.
*/
export type OrderSnapshot = {
schema_version: 1
billing_cycle: BillingInterval
items: OrderItem[]
subtotal: number
tax_rate: number
tax_amount: number
total: number
}
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
export type Order = {
id: string;
user_id: string;
status: 'pending' | 'completed' | 'cancelled';
total_amount: number;
items: any; // Snapshot of product and modules
billing_details: any;
created_at: string;
};
id: string
user_id: string | null
order_number: string
order_hash: string | null
end_customer_id: string | null
customer_data: CustomerSnapshot
order_data: OrderSnapshot
total_price: number
pdf_url: string | null
status: 'pending' | 'active' | 'cancelled'
created_at: string
}
// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────
export type LicenseOption = {
/** Stabiler Slug-Key, z. B. "caspos_basic__backoffice" */
key: string
label: string
active: boolean
billing: BillingInterval
price_net: number
}
export type LicenseBundle = {
customer_ref: string
generated_at: string
license_key_prefix: string
options: LicenseOption[]
monthly_total_net: number
one_time_total_net: number
}

View File

@@ -0,0 +1 @@
main

View File

@@ -0,0 +1 @@
v2.95.4

View File

@@ -0,0 +1,5 @@
-- Add sort_order column to categories for controlling display order in the order wizard
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0 NOT NULL;
-- Index for efficient ordering
CREATE INDEX IF NOT EXISTS idx_categories_sort_order ON public.categories(sort_order);

View File

@@ -0,0 +1,4 @@
-- Add is_required column to categories
-- When true (default), a product must be selected from this category in the order wizard
-- When false, the category is optional and can be skipped
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS is_required BOOLEAN DEFAULT true NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Add billing_interval to products to support subscription and one-time products
-- Values: 'one_time', 'monthly', 'yearly'
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS billing_interval TEXT DEFAULT 'monthly' NOT NULL
CHECK (billing_interval IN ('one_time', 'monthly', 'yearly'));

View File

@@ -0,0 +1,7 @@
-- Remove yearly from the billing_interval constraint, leaving only one_time and monthly
ALTER TABLE public.products DROP CONSTRAINT IF EXISTS products_billing_interval_check;
ALTER TABLE public.products ADD CONSTRAINT products_billing_interval_check
CHECK (billing_interval IN ('one_time', 'monthly'));
-- Update any existing yearly products to monthly
UPDATE public.products SET billing_interval = 'monthly' WHERE billing_interval = 'yearly';

View File

@@ -0,0 +1,27 @@
-- Migration: end_customers table + RLS + orders.end_customer_id
CREATE TABLE IF NOT EXISTS end_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
vat_id TEXT,
first_name TEXT,
last_name TEXT,
street TEXT,
zip TEXT,
city TEXT,
is_anonymized BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE end_customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Partner sehen nur eigene Endkunden" ON end_customers;
CREATE POLICY "Partner sehen nur eigene Endkunden"
ON end_customers FOR ALL
USING (auth.uid() = partner_id)
WITH CHECK (auth.uid() = partner_id);
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS end_customer_id UUID REFERENCES end_customers(id) ON DELETE SET NULL;

View File

@@ -0,0 +1 @@
update auth.users set email_confirmed_at = now() where email = 'test@test.com';