Compare commits
10 Commits
e6d801e9df
...
fb124b549c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb124b549c | ||
|
|
d96be1ace9 | ||
|
|
48920828ea | ||
|
|
5c393ecb57 | ||
|
|
f947798bf6 | ||
|
|
cc10352d72 | ||
|
|
1ec164cd7b | ||
|
|
cb6668d614 | ||
|
|
2888e6aeda | ||
|
|
e6cfc45c67 |
@@ -37,10 +37,6 @@ async function CategoryDataWrapper() {
|
|||||||
|
|
||||||
async function CreateCategoryAction() {
|
async function CreateCategoryAction() {
|
||||||
return (
|
return (
|
||||||
<CategoryDialog>
|
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export default function AdminLayout({
|
|||||||
<ShoppingCart className="w-5 h-5" />
|
<ShoppingCart className="w-5 h-5" />
|
||||||
Bestellungen
|
Bestellungen
|
||||||
</Link>
|
</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">
|
<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" />
|
<Settings className="w-5 h-5" />
|
||||||
Einstellungen
|
Einstellungen
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export default async function AdminOrdersPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Bestellungen & Rechnungen</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-white">Bestellungen & Rechnungen</h1>
|
||||||
<p className="text-muted-foreground">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
|
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ async function OrdersData() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-transparent">
|
<TableRow className="border-white/10 hover:bg-transparent">
|
||||||
<TableHead className="w-[100px]">ID</TableHead>
|
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
||||||
<TableHead>Datum</TableHead>
|
<TableHead className="text-slate-200">Datum</TableHead>
|
||||||
<TableHead>Kunde</TableHead>
|
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||||
<TableHead>Betrag</TableHead>
|
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="text-slate-200">Status</TableHead>
|
||||||
<TableHead className="text-right">Rechnung</TableHead>
|
<TableHead className="text-right text-slate-200">Rechnung</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -61,17 +61,17 @@ async function OrdersData() {
|
|||||||
) : (
|
) : (
|
||||||
orders?.map((order) => (
|
orders?.map((order) => (
|
||||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
|
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||||
{order.id.slice(0, 8)}...
|
{order.order_number ?? order.id.slice(0, 8) + '...'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-slate-300">
|
||||||
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
{new Date(order.created_at).toLocaleDateString('de-DE')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{order.customer_data?.company_name || 'Privatkunde'}</div>
|
<div className="font-medium text-white">{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="text-xs text-slate-400">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-semibold">
|
<TableCell className="font-semibold text-white">
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -1,87 +1,86 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
||||||
|
import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
||||||
|
|
||||||
|
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
|
||||||
|
async function getKpis() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ data: orders },
|
||||||
|
{ count: userCount },
|
||||||
|
{ count: productCount },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('orders').select('total_price, status'),
|
||||||
|
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('products').select('*', { count: 'exact', head: true }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalRevenue = (orders ?? []).reduce((sum, o) => sum + Number(o.total_price), 0)
|
||||||
|
const orderCount = (orders ?? []).length
|
||||||
|
|
||||||
|
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminDashboard() {
|
||||||
|
const { totalRevenue, orderCount, userCount, productCount } = await getKpis()
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
|
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
|
||||||
|
|
||||||
|
{/* KPI Cards – echte DB-Daten */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<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" />
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">€12,234.56</div>
|
<div className="text-2xl font-bold text-white">
|
||||||
<p className="text-xs text-muted-foreground">+20.1% zum Vormonat</p>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">Aus allen Bestellungen</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Bestellungen</CardTitle>
|
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">+573</div>
|
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">+12% zur Vorwoche</p>
|
<p className="text-xs text-slate-400">Gesamt</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Produkte</CardTitle>
|
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||||
<Package className="h-4 w-4 text-primary" />
|
<Package className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">12</div>
|
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">+2 neue Module</p>
|
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Kunden</CardTitle>
|
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||||
<Users className="h-4 w-4 text-primary" />
|
<Users className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">2,450</div>
|
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">+180 heute</p>
|
<p className="text-xs text-slate-400">Registrierte User</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
|
||||||
<Card className="col-span-4 glass-dark border-white/5">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Übersicht</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pl-2">
|
|
||||||
<div className="h-[200px] flex items-center justify-center text-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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Letzte Bestellungen – Client Component mit 30s-Polling */}
|
||||||
|
<AdminRecentOrders />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function AdminProductsPage() {
|
|||||||
<Package className="w-8 h-8 text-primary" />
|
<Package className="w-8 h-8 text-primary" />
|
||||||
Produkte & Module
|
Produkte & Module
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-slate-300">
|
||||||
Verwalte deine Hauptprodukte und deren zubuchbare Module.
|
Verwalte deine Hauptprodukte und deren zubuchbare Module.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
42
shop/app/admin/users/page.tsx
Normal file
42
shop/app/admin/users/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
shop/app/api/admin/recent-orders/route.ts
Normal file
30
shop/app/api/admin/recent-orders/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/recent-orders
|
||||||
|
* Gibt die letzten 10 Bestellungen zurück.
|
||||||
|
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
|
||||||
|
* Zugriff nur für authentifizierte User (Supabase Session).
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
// Session prüfen
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: orders, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('id, order_number, created_at, total_price, status, customer_data')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ orders })
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const defaultUrl = process.env.VERCEL_URL
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(defaultUrl),
|
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.",
|
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
213
shop/app/my-customers/[id]/page.tsx
Normal file
213
shop/app/my-customers/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
shop/app/my-customers/new/page.tsx
Normal file
94
shop/app/my-customers/new/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
shop/app/my-customers/page.tsx
Normal file
151
shop/app/my-customers/page.tsx
Normal 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
158
shop/app/my-orders/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import type { Order } from '@/lib/types'
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
|
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MyOrdersPage() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) redirect('/auth/login')
|
||||||
|
|
||||||
|
const { data: orders, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
|
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||||
|
Meine Bestellungen
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
Alle Ihre Bestellungen auf einen Blick – inkl. aktuellem Status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fehler */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||||
|
Fehler beim Laden der Bestellungen: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keine Bestellungen */}
|
||||||
|
{!error && (!orders || orders.length === 0) && (
|
||||||
|
<Card className="glass-dark border-white/10">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||||
|
<Package className="w-12 h-12 text-slate-600" />
|
||||||
|
<p className="text-slate-400 text-lg">Sie haben noch keine Bestellungen aufgegeben.</p>
|
||||||
|
<Link href="/order">
|
||||||
|
<Button>Jetzt konfigurieren</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bestellliste */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(orders ?? []).map((order) => {
|
||||||
|
const o = order as Order
|
||||||
|
const items = o.order_data?.items ?? []
|
||||||
|
return (
|
||||||
|
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-colors">
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{/* Kopfzeile */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Bestellnummer</p>
|
||||||
|
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-right">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Datum</p>
|
||||||
|
<p className="text-white text-sm">
|
||||||
|
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
|
||||||
|
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
||||||
|
{statusLabel[o.status] ?? o.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-right">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Gesamt</p>
|
||||||
|
<p className="font-bold text-white text-lg">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Produkte kurz */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
||||||
|
>
|
||||||
|
{item.product_name}
|
||||||
|
{item.selected_modules.length > 0 && (
|
||||||
|
<span className="text-slate-500 ml-1">+{item.selected_modules.length} Module</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aktionen */}
|
||||||
|
<div className="flex gap-2 flex-wrap pt-1">
|
||||||
|
<Link href={`/order/success?id=${o.id}`}>
|
||||||
|
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs">
|
||||||
|
Details ansehen
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{o.pdf_url && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
|
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" /> Rechnung ansehen
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
|
<a href={o.pdf_url} download>
|
||||||
|
<Download className="w-3 h-3 mr-1" /> PDF
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
|
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||||
import { OrderWizard } from '@/components/order-wizard'
|
import { OrderWizard } from '@/components/order-wizard'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
@@ -35,7 +36,8 @@ async function OrderDataWrapper() {
|
|||||||
|
|
||||||
const products = await getProducts()
|
const products = await getProducts()
|
||||||
const categories = await getCategories()
|
const categories = await getCategories()
|
||||||
|
const endCustomers = await getEndCustomers()
|
||||||
|
|
||||||
// Fetch profile if exists
|
// Fetch profile if exists
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
@@ -43,5 +45,5 @@ async function OrderDataWrapper() {
|
|||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
return <OrderWizard products={products} categories={categories} initialProfile={profile} />
|
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
|
||||||
}
|
}
|
||||||
|
|||||||
157
shop/app/order/success/page.tsx
Normal file
157
shop/app/order/success/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import type { Order } from '@/lib/types'
|
||||||
|
|
||||||
|
export default async function OrderSuccessPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ id?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await searchParams
|
||||||
|
if (!id) redirect('/')
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
// Authentifizierung prüfen
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) redirect('/auth/login')
|
||||||
|
|
||||||
|
// Bestellung laden und prüfen ob sie dem eingeloggten User gehört
|
||||||
|
const { data: order, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !order) redirect('/')
|
||||||
|
|
||||||
|
const o = order as Order
|
||||||
|
const items = o.order_data?.items ?? []
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#020617] text-white flex items-center justify-center px-4 py-16">
|
||||||
|
<div className="w-full max-w-2xl space-y-6">
|
||||||
|
{/* Erfolgsmeldung */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-24 h-24 bg-green-500/10 rounded-full flex items-center justify-center mx-auto border border-green-500/30 animate-pulse">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||||
|
Bestellung eingegangen!
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-lg">
|
||||||
|
Ihre Bestellung wurde erfolgreich gespeichert.
|
||||||
|
</p>
|
||||||
|
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
|
||||||
|
#{o.order_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bestell-Details */}
|
||||||
|
<Card className="glass-dark border-white/10">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-white">Bestellübersicht</CardTitle>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
o.status === 'active'
|
||||||
|
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
|
: o.status === 'cancelled'
|
||||||
|
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusLabel[o.status] ?? o.status}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Produkte */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-semibold text-white">{item.product_name}</span>
|
||||||
|
<span className="text-white">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||||
|
<span className="text-slate-400 text-xs ml-1">
|
||||||
|
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.selected_modules.map((mod, j) => (
|
||||||
|
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||||
|
<span>+ {mod.module_name}</span>
|
||||||
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-white/10" />
|
||||||
|
|
||||||
|
{/* Gesamtbetrag */}
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span className="text-slate-300">Gesamtbetrag</span>
|
||||||
|
<span className="text-gradient">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-white/10" />
|
||||||
|
|
||||||
|
{/* Kundendaten */}
|
||||||
|
<div className="text-sm text-slate-400 space-y-1">
|
||||||
|
<p className="font-semibold text-white">{o.customer_data?.company_name}</p>
|
||||||
|
<p>{o.customer_data?.first_name} {o.customer_data?.last_name}</p>
|
||||||
|
<p>{o.customer_data?.address}, {o.customer_data?.zip_code} {o.customer_data?.city}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF-Download */}
|
||||||
|
{o.pdf_url && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
||||||
|
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" /> Rechnung ansehen
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<a href={o.pdf_url} download>
|
||||||
|
<Download className="w-4 h-4 mr-2" /> Herunterladen
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Aktions-Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link href="/my-orders" className="flex-1">
|
||||||
|
<Button variant="outline" className="w-full border-white/10 hover:bg-white/5">
|
||||||
|
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||||
|
Meine Bestellungen
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="flex-1">
|
||||||
|
<Button className="w-full">
|
||||||
|
Zurück zur Startseite <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,17 +5,29 @@ import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
|
<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">
|
<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="#">
|
<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">
|
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||||
<Rocket className="h-5 w-5 text-white" />
|
<Rocket className="h-5 w-5 text-white" />
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
|
<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">
|
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
|
||||||
Bestellen
|
Bestellen
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-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">
|
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
|
||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
@@ -30,25 +42,25 @@ export default function Home() {
|
|||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<div className="flex flex-col items-center space-y-4 text-center">
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
||||||
Version 2.0 ist da
|
⚠️ Demo-Umgebung für Testbestellungen
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
|
<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>
|
</h1>
|
||||||
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-4 mt-8">
|
<div className="space-x-4 mt-8">
|
||||||
<Link href="/order">
|
<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">
|
<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>
|
</Button>
|
||||||
</Link>
|
</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">
|
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
|
||||||
Admin Bereich
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,29 +75,29 @@ export default function Home() {
|
|||||||
<div className="p-3 bg-primary/10 rounded-xl">
|
<div className="p-3 bg-primary/10 rounded-xl">
|
||||||
<Cpu className="h-8 w-8 text-primary" />
|
<Cpu className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Modulare Architektur</h3>
|
<h3 className="text-xl font-bold">Handel</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>
|
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
|
<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">
|
<div className="p-3 bg-primary/10 rounded-xl">
|
||||||
<Shield className="h-8 w-8 text-primary" />
|
<Shield className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Maximale Sicherheit</h3>
|
<h3 className="text-xl font-bold">Gastronomie</h3>
|
||||||
<p className="text-slate-400">Enterprise-grade Security mit Supabase Auth und granularen Row Level Security Policies.</p>
|
<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>
|
||||||
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
|
<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">
|
<div className="p-3 bg-primary/10 rounded-xl">
|
||||||
<Rocket className="h-8 w-8 text-primary" />
|
<Rocket className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Sofort Einsatzbereit</h3>
|
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
|
||||||
<p className="text-slate-400">Nach der Bestellung wird Ihre Instanz automatisch provisioniert und ist in Minuten online.</p>
|
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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]">
|
<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">
|
<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="#">Impressum</Link>
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
|
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { createCategory, updateCategory } from '@/lib/actions/products'
|
import { createCategory, updateCategory } from '@/lib/actions/products'
|
||||||
import { Category } from '@/lib/types'
|
import { Category } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
@@ -32,12 +33,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Cloud, Utensils, HardDrive, Package } from 'lucide-react'
|
import { Cloud, Utensils, HardDrive, Package } from 'lucide-react'
|
||||||
|
|
||||||
const categorySchema = z.object({
|
const categorySchema = z.object({
|
||||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
icon: z.string().default('Package'),
|
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>
|
type CategoryFormValues = z.infer<typeof categorySchema>
|
||||||
@@ -46,7 +50,7 @@ export function CategoryDialog({
|
|||||||
children,
|
children,
|
||||||
category
|
category
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode,
|
children?: React.ReactNode,
|
||||||
category?: Category
|
category?: Category
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -57,6 +61,8 @@ export function CategoryDialog({
|
|||||||
name: category?.name || '',
|
name: category?.name || '',
|
||||||
description: category?.description || '',
|
description: category?.description || '',
|
||||||
icon: category?.icon || 'Package',
|
icon: category?.icon || 'Package',
|
||||||
|
sort_order: category?.sort_order ?? 0,
|
||||||
|
is_required: category?.is_required ?? true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,71 +89,124 @@ export function CategoryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<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">
|
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
|
<DialogTitle>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-slate-300">
|
||||||
Definieren Sie eine Kategorie für Ihre Produkte.
|
Definieren Sie eine Kategorie für Ihre Produkte.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
<FormField
|
<ScrollArea className="max-h-[60vh] pr-4">
|
||||||
control={form.control}
|
<div className="space-y-4 py-2">
|
||||||
name="name"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>Name</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10" {...field} />
|
<FormLabel className="text-white">Name</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10 text-white" {...field} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<FormField
|
)}
|
||||||
control={form.control}
|
/>
|
||||||
name="description"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="description"
|
||||||
<FormLabel>Beschreibung</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10" {...field} />
|
<FormLabel className="text-white">Beschreibung</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10 text-white" {...field} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<FormField
|
)}
|
||||||
control={form.control}
|
/>
|
||||||
name="icon"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="icon"
|
||||||
<FormLabel>Icon</FormLabel>
|
render={({ field }) => (
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel className="text-white">Icon</FormLabel>
|
||||||
<SelectTrigger className="bg-white/5 border-white/10">
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<SelectValue placeholder="Icon wählen" />
|
<FormControl>
|
||||||
</SelectTrigger>
|
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||||
</FormControl>
|
<SelectValue placeholder="Icon wählen" />
|
||||||
<SelectContent className="glass-dark border-white/10 text-white">
|
</SelectTrigger>
|
||||||
{icons.map(({ name, Icon }) => (
|
</FormControl>
|
||||||
<SelectItem key={name} value={name}>
|
<SelectContent className="glass-dark border-white/10 text-white">
|
||||||
<div className="flex items-center gap-2">
|
{icons.map(({ name, Icon }) => (
|
||||||
<Icon className="w-4 h-4" />
|
<SelectItem key={name} value={name} className="text-white hover:bg-white/10">
|
||||||
{name}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Icon className="w-4 h-4" />
|
||||||
</SelectItem>
|
{name}
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
))}
|
||||||
<FormMessage />
|
</SelectContent>
|
||||||
</FormItem>
|
</Select>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<DialogFooter>
|
)}
|
||||||
|
/>
|
||||||
|
<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">
|
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
|
||||||
{category ? 'Aktualisieren' : 'Erstellen'}
|
{category ? 'Aktualisieren' : 'Erstellen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { deleteCategory } from '@/lib/actions/products'
|
import { deleteCategory } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CategoryDialog } from './category-dialog'
|
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">
|
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Kategorienübersicht</CardTitle>
|
<CardTitle>Kategorienübersicht</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-slate-400">
|
||||||
Verwalten Sie die Hauptkategorien für Ihre Produkte.
|
Verwalten Sie die Hauptkategorien für Ihre Produkte.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -47,10 +48,16 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
<TableRow className="border-white/10 hover:bg-white/5">
|
||||||
<TableHead className="text-muted-foreground">Icon</TableHead>
|
<TableHead className="text-white font-bold w-10">Icon</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
<TableHead className="text-white font-bold">Name</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Beschreibung</TableHead>
|
<TableHead className="text-white font-bold">Beschreibung</TableHead>
|
||||||
<TableHead className="text-right text-muted-foreground">Aktionen</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -61,8 +68,20 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
{getIcon(category.icon)}
|
{getIcon(category.icon)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{category.name}</TableCell>
|
<TableCell className="font-medium text-white">{category.name}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{category.description}</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">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<CategoryDialog category={category}>
|
<CategoryDialog category={category}>
|
||||||
@@ -84,7 +103,7 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
))}
|
))}
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
<TableRow>
|
<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.
|
Keine Kategorien gefunden.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
|
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Category } from '@/lib/types'
|
import { Category, Product } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -52,24 +52,31 @@ const productSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||||
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
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([]),
|
modules: z.array(moduleSchema).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProductFormValues = z.infer<typeof productSchema>
|
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 [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as any,
|
resolver: zodResolver(productSchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: product?.name || '',
|
||||||
description: '',
|
description: product?.description || '',
|
||||||
base_price: 0,
|
base_price: product?.base_price || 0,
|
||||||
category_id: '',
|
category_id: product?.category_id || '',
|
||||||
is_active: true,
|
billing_interval: product?.billing_interval || 'monthly',
|
||||||
modules: [],
|
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) : [],
|
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||||
exclusions: m.exclusions ? m.exclusions.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)
|
setOpen(false)
|
||||||
form.reset()
|
if (!product) form.reset()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating product:', error)
|
console.error('Error saving product:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-primary hover:bg-primary/90">
|
{children || (
|
||||||
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
|
<Button className="bg-primary hover:bg-primary/90">
|
||||||
</Button>
|
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold">Neues Produkt erstellen</DialogTitle>
|
<DialogTitle className="text-2xl font-bold">
|
||||||
<DialogDescription className="text-muted-foreground">
|
{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.
|
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 overflow-hidden flex flex-col">
|
<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="space-y-6 py-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -119,9 +136,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Produktname</FormLabel>
|
<FormLabel className="text-white">Produktname</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -132,9 +149,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name="base_price"
|
name="base_price"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Basispreis (€)</FormLabel>
|
<FormLabel className="text-white">Basispreis (€)</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -147,16 +164,16 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name="category_id"
|
name="category_id"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Kategorie</FormLabel>
|
<FormLabel className="text-white">Kategorie</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="bg-white/5 border-white/10">
|
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||||
<SelectValue placeholder="Kategorie wählen" />
|
<SelectValue placeholder="Kategorie wählen" className="text-white" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent className="glass-dark border-white/10 text-white">
|
<SelectContent className="glass-dark border-white/10 text-white">
|
||||||
{categories.map(cat => (
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Beschreibung</FormLabel>
|
<FormLabel className="text-white">Beschreibung</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -216,9 +264,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name={`modules.${index}.name`}
|
name={`modules.${index}.name`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Modulname</FormLabel>
|
<FormLabel className="text-white">Modulname</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -229,9 +277,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name={`modules.${index}.price`}
|
name={`modules.${index}.price`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Aufpreis (€)</FormLabel>
|
<FormLabel className="text-white">Aufpreis (€)</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -245,9 +293,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name={`modules.${index}.requirements`}
|
name={`modules.${index}.requirements`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Benötigt (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -258,9 +306,9 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
name={`modules.${index}.exclusions`}
|
name={`modules.${index}.exclusions`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Schließt aus (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -281,7 +329,7 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1 leading-none">
|
<div className="space-y-1 leading-none">
|
||||||
<FormLabel>Erforderlich</FormLabel>
|
<FormLabel className="text-white">Erforderlich</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -291,7 +339,7 @@ export function CreateProductDialog({ categories }: { categories: Category[] })
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{fields.length === 0 && (
|
{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.
|
Noch keine Module hinzugefügt.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Product } from '@/lib/types'
|
import { Product, Category } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,8 +15,9 @@ import { Edit2, Trash2, Layers } from 'lucide-react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { deleteProduct } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
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 [products, setProducts] = useState(initialProducts)
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
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">
|
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Produktübersicht</CardTitle>
|
<CardTitle>Produktübersicht</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-slate-400">
|
||||||
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -38,17 +39,17 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
<TableRow className="border-white/10 hover:bg-white/5">
|
||||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
<TableHead className="text-white font-bold">Name</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Basispreis</TableHead>
|
<TableHead className="text-white font-bold">Basispreis</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Module</TableHead>
|
<TableHead className="text-white font-bold">Module</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
<TableHead className="text-white font-bold">Status</TableHead>
|
||||||
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead>
|
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
<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 flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{product.name}
|
{product.name}
|
||||||
@@ -57,27 +58,35 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
|
|||||||
{product.category.name}
|
{product.category.name}
|
||||||
</Badge>
|
</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>
|
</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>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Layers className="w-3 h-3 text-primary" />
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={product.is_active ? "default" : "secondary"}>
|
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
||||||
{product.is_active ? "Aktiv" : "Inaktiv"}
|
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
|
<CreateProductDialog categories={categories} product={product}>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
|
||||||
</Button>
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CreateProductDialog>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -92,7 +101,7 @@ export function ProductList({ initialProducts }: { initialProducts: Product[] })
|
|||||||
))}
|
))}
|
||||||
{products.length === 0 && (
|
{products.length === 0 && (
|
||||||
<TableRow>
|
<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!
|
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
140
shop/components/admin/recent-orders.tsx
Normal file
140
shop/components/admin/recent-orders.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type RecentOrder = {
|
||||||
|
id: string
|
||||||
|
order_number: string
|
||||||
|
created_at: string
|
||||||
|
total_price: number
|
||||||
|
status: string
|
||||||
|
customer_data?: {
|
||||||
|
company_name?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
|
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
export function AdminRecentOrders() {
|
||||||
|
const [orders, setOrders] = useState<RecentOrder[]>([])
|
||||||
|
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
||||||
|
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
||||||
|
const knownIds = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const fetchOrders = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const { orders: fresh } = await res.json()
|
||||||
|
|
||||||
|
// Neue Bestellungen erkennen (Highlight)
|
||||||
|
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
||||||
|
const newOnes = new Set<string>()
|
||||||
|
freshIds.forEach(id => {
|
||||||
|
if (!knownIds.current.has(id)) newOnes.add(id)
|
||||||
|
})
|
||||||
|
knownIds.current = freshIds
|
||||||
|
|
||||||
|
setOrders(fresh)
|
||||||
|
setNewOrderIds(newOnes)
|
||||||
|
setLastChecked(new Date())
|
||||||
|
|
||||||
|
// Highlight nach 5 Sekunden entfernen
|
||||||
|
if (newOnes.size > 0) {
|
||||||
|
setTimeout(() => setNewOrderIds(new Set()), 5000)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Polling error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders()
|
||||||
|
const interval = setInterval(fetchOrders, POLL_INTERVAL_MS)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Card className="col-span-4 glass-dark border-white/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Übersicht</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2">
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
|
||||||
|
[Umsatz-Chart – folgt in nächstem Update]
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="col-span-3 glass-dark border-white/5">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<CardTitle>Neue Bestellungen</CardTitle>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
Aktualisiert: {lastChecked.toLocaleTimeString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic text-center py-8">Noch keine Bestellungen</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orders.map(order => {
|
||||||
|
const isNew = newOrderIds.has(order.id)
|
||||||
|
const customerName =
|
||||||
|
order.customer_data?.company_name ||
|
||||||
|
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
|
||||||
|
'Unbekannt'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={order.id} href="/admin/orders" className="block">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${
|
||||||
|
isNew
|
||||||
|
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
|
||||||
|
{customerName.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{customerName}</p>
|
||||||
|
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 space-y-1">
|
||||||
|
<p className="font-medium text-white text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||||
|
</p>
|
||||||
|
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>
|
||||||
|
{statusLabel[order.status] ?? order.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
shop/components/admin/user-dialog.tsx
Normal file
113
shop/components/admin/user-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
shop/components/admin/user-list.tsx
Normal file
119
shop/components/admin/user-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Product, ProductModule, Profile } from '@/lib/types'
|
import { Product, ProductModule, Profile, EndCustomer } from '@/lib/types'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
@@ -10,108 +11,201 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, 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 { submitOrder } from '@/lib/actions/orders'
|
||||||
|
import { createEndCustomer } from '@/lib/actions/end-customers'
|
||||||
import { Category } from '@/lib/types'
|
import { Category } from '@/lib/types'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 [step, setStep] = useState(1)
|
||||||
const [activeCategory, setActiveCategory] = useState<string>(categories[0]?.id || 'all')
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
|
|
||||||
company_name: '',
|
// Partner-Profil (Fallback)
|
||||||
vat_id: '',
|
const [customerData] = useState<Partial<Profile>>(initialProfile || {})
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
// Endkunden-State
|
||||||
address: '',
|
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
|
||||||
city: '',
|
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
|
||||||
zip_code: ''
|
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(() => {
|
// Per-category selection map: categoryId -> { productId, moduleIds }
|
||||||
if (activeCategory === 'all') return products
|
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
||||||
return products.filter(p => p.category_id === activeCategory)
|
const init: Record<string, CategorySelection> = {}
|
||||||
}, [products, activeCategory])
|
categories.forEach(cat => {
|
||||||
|
const first = products.find(p => p.category_id === cat.id)
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(filteredProducts[0] || null)
|
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
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
|
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 nextStep = () => setStep(s => s + 1)
|
||||||
const prevStep = () => setStep(s => s - 1)
|
const prevStep = () => setStep(s => s - 1)
|
||||||
|
|
||||||
const toggleModule = (id: string) => {
|
// Neuen Endkunden inline anlegen
|
||||||
const module = selectedProduct?.modules?.find(m => m.id === id)
|
const handleCreateCustomer = async () => {
|
||||||
if (!module) return
|
if (!newCustomerForm.company_name.trim()) return
|
||||||
|
setIsCreatingCustomer(true)
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
await submitOrder({
|
const created = await createEndCustomer(newCustomerForm)
|
||||||
product_id: selectedProduct.id,
|
setEndCustomers(prev => [...prev, created])
|
||||||
selected_modules: selectedModules,
|
setSelectedEndCustomerId(created.id)
|
||||||
total_amount: totalPrice,
|
setCustomerMode('select')
|
||||||
customer_details: customerData
|
setNewCustomerForm({ company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '' })
|
||||||
})
|
} catch (e) {
|
||||||
alert('Bestellung erfolgreich aufgegeben!')
|
alert('Fehler beim Anlegen des Kunden.')
|
||||||
window.location.href = '/'
|
} finally {
|
||||||
} catch (error) {
|
setIsCreatingCustomer(false)
|
||||||
console.error(error)
|
|
||||||
alert('Fehler bei der Bestellung.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
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 */}
|
{/* Progress Stepper */}
|
||||||
<div className="flex justify-between mb-12 relative">
|
<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" />
|
<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
|
<div
|
||||||
key={s}
|
key={s}
|
||||||
className={`relative z-10 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
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}
|
{step > s ? <Check className="w-6 h-6" /> : s}
|
||||||
@@ -120,6 +214,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
{/* ───── Step 1: Product Selection per Category ───── */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step1"
|
key="step1"
|
||||||
@@ -129,152 +224,226 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-3 gap-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">
|
<Card className="glass-dark border-white/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl flex items-center gap-2">
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
<ShoppingCart className="w-6 h-6 text-primary" />
|
<ShoppingCart className="w-6 h-6 text-primary" />
|
||||||
Software wählen
|
Software wählen
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-8">
|
||||||
{/* Category Tabs */}
|
{categories.map((cat, idx) => {
|
||||||
<div className="space-y-4">
|
const catProducts = products.filter(p => p.category_id === cat.id)
|
||||||
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Kategorie</Label>
|
const sel = selections[cat.id]
|
||||||
<Tabs value={activeCategory} onValueChange={(val) => {
|
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||||
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>
|
|
||||||
|
|
||||||
<Separator className="bg-white/10" />
|
return (
|
||||||
|
<div key={cat.id}>
|
||||||
|
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Category header */}
|
||||||
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Lösung</Label>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<RadioGroup
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
value={selectedProduct?.id}
|
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
)
|
||||||
)}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Summary */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="glass-dark border-primary/20 sticky top-6">
|
<Card className="glass-dark border-primary/20 sticky top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Zusammenfassung</CardTitle>
|
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
{categories.map(cat => {
|
||||||
<span className="text-muted-foreground">Basispreis:</span>
|
const sel = selections[cat.id]
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(selectedProduct?.base_price || 0)}</span>
|
const prod = products.find(p => p.id === sel?.productId)
|
||||||
</div>
|
if (!prod) return (
|
||||||
{selectedModules.map(modId => {
|
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
|
||||||
const mod = selectedProduct?.modules?.find(m => m.id === modId)
|
{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 (
|
return (
|
||||||
<div key={modId} className="flex justify-between text-sm">
|
<div key={cat.id} className="space-y-1">
|
||||||
<span className="text-muted-foreground">{mod?.name}:</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.price || 0)}</span>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<span>Gesamt:</span>
|
<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>
|
</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>
|
</CardContent>
|
||||||
<CardFooter>
|
<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" />
|
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -284,6 +453,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Step 2: Endkunde wählen / anlegen ───── */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step2"
|
key="step2"
|
||||||
@@ -296,75 +466,127 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl flex items-center gap-2">
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
<User className="w-6 h-6 text-primary" />
|
<User className="w-6 h-6 text-primary" />
|
||||||
Kundendaten
|
Für wen bestellen Sie?
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="grid md:grid-cols-2 gap-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
{/* Modus-Toggle */}
|
||||||
<Label>Firmenname</Label>
|
<div className="flex gap-2">
|
||||||
<Input
|
<Button
|
||||||
value={customerData.company_name || ''}
|
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||||
onChange={(e) => setCustomerData({...customerData, company_name: e.target.value})}
|
size="sm"
|
||||||
placeholder="GmbH / Einzelunternehmen"
|
onClick={() => setCustomerMode('select')}
|
||||||
className="bg-white/5 border-white/10"
|
className="gap-2"
|
||||||
/>
|
>
|
||||||
</div>
|
<Building2 className="w-4 h-4" /> Bestandskunde wählen
|
||||||
<div className="space-y-2">
|
</Button>
|
||||||
<Label>USt-IdNr.</Label>
|
<Button
|
||||||
<Input
|
variant={customerMode === 'create' ? 'default' : 'ghost'}
|
||||||
value={customerData.vat_id || ''}
|
size="sm"
|
||||||
onChange={(e) => setCustomerData({...customerData, vat_id: e.target.value})}
|
onClick={() => setCustomerMode('create')}
|
||||||
placeholder="DE123456789"
|
className="gap-2"
|
||||||
className="bg-white/5 border-white/10"
|
>
|
||||||
/>
|
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
|
||||||
</div>
|
</Button>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||||
<Button variant="ghost" onClick={prevStep}>
|
<Button variant="ghost" onClick={prevStep}>
|
||||||
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
||||||
</Button>
|
</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" />
|
Prüfung & Abschluss <ChevronRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -372,6 +594,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Step 3: Review & Submit ───── */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step3"
|
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">
|
<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" />
|
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl">Bestellung prüfen</CardTitle>
|
<CardTitle className="text-3xl text-white">Bestellung prüfen</CardTitle>
|
||||||
<CardDescription>Fast fertig! Bitte überprüfen Sie Ihre Auswahl.</CardDescription>
|
<CardDescription className="text-slate-300">
|
||||||
|
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 text-left">
|
<CardContent className="space-y-6 text-left">
|
||||||
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
||||||
<div className="flex justify-between font-bold text-lg">
|
{categories.map(cat => {
|
||||||
<span>{selectedProduct?.name}</span>
|
const sel = selections[cat.id]
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat</span>
|
const prod = products.find(p => p.id === sel?.productId)
|
||||||
</div>
|
if (!prod) return null
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
const catTotal =
|
||||||
<p>Inklusive: {selectedModules.length > 0 ? selectedModules.map(id => selectedProduct?.modules?.find(m => m.id === id)?.name).join(', ') : 'Keine Zusatzmodule'}</p>
|
Number(prod.base_price) +
|
||||||
<Separator className="bg-white/10 my-2" />
|
sel.moduleIds.reduce((acc, mId) => {
|
||||||
<p>{customerData.company_name}</p>
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<span>Gesamtbetrag:</span>
|
||||||
Mit dem Klick auf "Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die Datenschutzerklärung.
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<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}>
|
<Button
|
||||||
Kostenpflichtig bestellen
|
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>
|
||||||
<Button variant="ghost" onClick={prevStep} className="w-full">
|
<Button variant="ghost" onClick={prevStep} className="w-full">
|
||||||
Noch etwas ändern
|
Noch etwas ändern
|
||||||
|
|||||||
145
shop/lib/actions/end-customers.ts
Normal file
145
shop/lib/actions/end-customers.ts
Normal 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}`)
|
||||||
|
}
|
||||||
@@ -5,89 +5,146 @@ import { revalidatePath } from 'next/cache'
|
|||||||
import { InvoicePDF } from '@/components/invoice-pdf'
|
import { InvoicePDF } from '@/components/invoice-pdf'
|
||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import React from 'react'
|
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: {
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
product_id: string;
|
|
||||||
selected_modules: string[];
|
/**
|
||||||
total_amount: number;
|
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||||
customer_details: any;
|
* 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 supabase = await createClient()
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) throw new Error('Not authenticated')
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
// 1. Fetch Product and Module Details for Invoice
|
// 1. Snapshots aufbauen
|
||||||
const { data: product } = await supabase
|
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||||
.from('products')
|
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('*')
|
.select('*')
|
||||||
.eq('id', orderData.product_id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.eq('order_hash', orderHash)
|
||||||
|
.gte('created_at', thirtySecondsAgo)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
const { data: modules } = await supabase
|
if (existingOrder) {
|
||||||
.from('product_modules')
|
console.warn('[submitOrder] Duplicate order detected, returning existing order:', existingOrder.order_number)
|
||||||
.select('*')
|
return existingOrder as Order
|
||||||
.in('id', orderData.selected_modules)
|
}
|
||||||
|
|
||||||
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
|
const { data: order, error: orderError } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.insert([{
|
.insert([{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
total_price: orderData.total_amount, // Matched column name in initial_schema.sql
|
order_number: orderNumber,
|
||||||
order_data: {
|
order_hash: orderHash,
|
||||||
product_id: orderData.product_id,
|
end_customer_id: endCustomerId ?? null,
|
||||||
modules: orderData.selected_modules
|
total_price: orderSnapshot.total,
|
||||||
},
|
customer_data: customerSnapshot,
|
||||||
customer_data: orderData.customer_details,
|
order_data: orderSnapshot,
|
||||||
status: 'pending'
|
status: 'pending',
|
||||||
}])
|
}])
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (orderError) throw orderError
|
if (orderError) throw orderError
|
||||||
|
|
||||||
// 3. Generate PDF
|
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
||||||
try {
|
try {
|
||||||
const buffer = await renderToBuffer(
|
const buffer = await renderToBuffer(
|
||||||
React.createElement(InvoicePDF, {
|
React.createElement(InvoicePDF, {
|
||||||
order: order,
|
order,
|
||||||
product: product,
|
customer: customerSnapshot,
|
||||||
modules: modules || [],
|
orderSnapshot,
|
||||||
customer: orderData.customer_details
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Upload to Supabase Storage
|
// 6. PDF in Supabase Storage hochladen
|
||||||
const fileName = `invoice_${order.id}.pdf`
|
const fileName = `invoice_${order.id}.pdf`
|
||||||
const { data: uploadData, error: uploadError } = await supabase
|
const { error: uploadError } = await supabase
|
||||||
.storage
|
.storage
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.upload(fileName, buffer, {
|
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
|
||||||
contentType: 'application/pdf',
|
|
||||||
upsert: true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
console.error('PDF Upload Error:', uploadError)
|
console.error('PDF Upload Error:', uploadError)
|
||||||
} else {
|
} else {
|
||||||
// 5. Update Order with PDF URL
|
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
||||||
const { data: publicUrlData } = supabase
|
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||||
.storage
|
order.pdf_url = publicUrlData.publicUrl
|
||||||
.from('invoices')
|
|
||||||
.getPublicUrl(fileName)
|
|
||||||
|
|
||||||
await supabase
|
|
||||||
.from('orders')
|
|
||||||
.update({ pdf_url: publicUrlData.publicUrl })
|
|
||||||
.eq('id', order.id)
|
|
||||||
}
|
}
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
console.error('PDF Generation Error:', pdfError)
|
console.error('PDF Generation Error:', pdfError)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/protected')
|
revalidatePath('/protected')
|
||||||
return order
|
revalidatePath('/my-orders')
|
||||||
|
return order as Order
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { Product, ProductModule } from '../types'
|
import { Category, Product, ProductModule } from '../types'
|
||||||
|
|
||||||
export async function getProducts() {
|
export async function getProducts() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -20,7 +20,7 @@ export async function getCategories() {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('name', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data as Category[]
|
return data as Category[]
|
||||||
@@ -107,9 +107,8 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
if (modules.length > 0) {
|
if (modules.length > 0) {
|
||||||
const modulesWithId = modules.map(m => ({
|
const modulesWithId = modules.map(m => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description,
|
description: m.description || null,
|
||||||
price: m.additional_price, // Match schema column name 'price'
|
price: m.price,
|
||||||
is_required: m.is_required,
|
|
||||||
requirements: m.requirements || [],
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions || [],
|
exclusions: m.exclusions || [],
|
||||||
product_id: id
|
product_id: id
|
||||||
|
|||||||
50
shop/lib/actions/users.ts
Normal file
50
shop/lib/actions/users.ts
Normal 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
|
||||||
|
}
|
||||||
261
shop/lib/license-transform.ts
Normal file
261
shop/lib/license-transform.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
shop/lib/supabase/admin.ts
Normal file
16
shop/lib/supabase/admin.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,56 +1,164 @@
|
|||||||
|
// ─── Katalog (gelesen aus Supabase) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BillingInterval = 'one_time' | 'monthly'
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null | undefined;
|
description?: string | null
|
||||||
base_price: number;
|
base_price: number
|
||||||
is_active: boolean;
|
tax_rate: number
|
||||||
created_at: string;
|
billing_interval: BillingInterval
|
||||||
updated_at: string;
|
category_id?: string | null
|
||||||
category_id?: string | null;
|
category?: Category | null
|
||||||
category?: Category | null;
|
modules?: ProductModule[]
|
||||||
modules?: ProductModule[];
|
created_at: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null;
|
description?: string | null
|
||||||
icon?: string | null;
|
icon?: string | null
|
||||||
created_at: string;
|
sort_order: number
|
||||||
};
|
is_required: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ProductModule = {
|
export type ProductModule = {
|
||||||
id: string;
|
id: string
|
||||||
product_id: string;
|
product_id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null | undefined;
|
description?: string | null
|
||||||
price: number;
|
price: number
|
||||||
is_required: boolean;
|
requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
|
||||||
is_active?: boolean;
|
exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
|
||||||
requirements: string[];
|
created_at: string
|
||||||
exclusions: string[];
|
}
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
id: string;
|
id: string
|
||||||
company_name: string | null;
|
company_name: string | null
|
||||||
vat_id: string | null;
|
vat_id: string | null
|
||||||
first_name: string | null;
|
first_name: string | null
|
||||||
last_name: string | null;
|
last_name: string | null
|
||||||
address: string | null;
|
address: string | null
|
||||||
city: string | null;
|
city: string | null
|
||||||
zip_code: string | null;
|
zip_code: string | null
|
||||||
country: string;
|
country: string
|
||||||
updated_at: 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 = {
|
export type Order = {
|
||||||
id: string;
|
id: string
|
||||||
user_id: string;
|
user_id: string | null
|
||||||
status: 'pending' | 'completed' | 'cancelled';
|
order_number: string
|
||||||
total_amount: number;
|
order_hash: string | null
|
||||||
items: any; // Snapshot of product and modules
|
end_customer_id: string | null
|
||||||
billing_details: any;
|
customer_data: CustomerSnapshot
|
||||||
created_at: string;
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
1
shop/supabase/.branches/_current_branch
Normal file
1
shop/supabase/.branches/_current_branch
Normal file
@@ -0,0 +1 @@
|
|||||||
|
main
|
||||||
1
shop/supabase/.temp/cli-latest
Normal file
1
shop/supabase/.temp/cli-latest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v2.95.4
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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'));
|
||||||
@@ -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';
|
||||||
27
shop/supabase/migrations/20260617_end_customers.sql
Normal file
27
shop/supabase/migrations/20260617_end_customers.sql
Normal 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;
|
||||||
1
shop/supabase/snippets/Untitled query 657.sql
Normal file
1
shop/supabase/snippets/Untitled query 657.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
update auth.users set email_confirmed_at = now() where email = 'test@test.com';
|
||||||
Reference in New Issue
Block a user