feat: add admin dashboard revenue chart and fix empty customer order bug
All checks were successful
Staging Build / build (push) Successful in 2m35s
All checks were successful
Staging Build / build (push) Successful in 2m35s
This commit is contained in:
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/recent-orders
|
* GET /api/admin/recent-orders
|
||||||
* Gibt die letzten 10 Bestellungen zurück.
|
* Gibt die letzten 10 Bestellungen und Umsatzdaten der letzten 6 Monate zurück.
|
||||||
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
|
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
|
||||||
* Zugriff nur für authentifizierte User (Supabase Session).
|
* Zugriff nur für authentifizierte User (Supabase Session).
|
||||||
*/
|
*/
|
||||||
@@ -16,6 +16,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Letzte 10 Bestellungen laden
|
||||||
const { data: orders, error } = await supabase
|
const { data: orders, error } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.select('id, order_number, created_at, total_price, status, customer_data')
|
.select('id, order_number, created_at, total_price, status, customer_data')
|
||||||
@@ -26,5 +27,47 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ orders })
|
// 2. Umsatzdaten der letzten 6 Monate für das Chart ermitteln
|
||||||
|
const sixMonthsAgo = new Date()
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
|
||||||
|
sixMonthsAgo.setDate(1)
|
||||||
|
sixMonthsAgo.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const { data: chartOrders, error: chartError } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('total_price, created_at')
|
||||||
|
.neq('status', 'cancelled')
|
||||||
|
.gte('created_at', sixMonthsAgo.toISOString())
|
||||||
|
|
||||||
|
let chartData: { name: string; value: number }[] = []
|
||||||
|
if (!chartError && chartOrders) {
|
||||||
|
const monthlyData: Record<string, number> = {}
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// Die letzten 6 Monate initialisieren
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const key = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
|
||||||
|
monthlyData[key] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Werte aufsummieren
|
||||||
|
chartOrders.forEach(o => {
|
||||||
|
const date = new Date(o.created_at)
|
||||||
|
const key = date.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
|
||||||
|
if (key in monthlyData) {
|
||||||
|
monthlyData[key] += Number(o.total_price)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// In Array umwandeln
|
||||||
|
chartData = Object.entries(monthlyData).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: Math.round(value * 100) / 100
|
||||||
|
}))
|
||||||
|
} else if (chartError) {
|
||||||
|
console.error('Chart data fetch error:', chartError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ orders, chartData })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { RevenueChart } from './revenue-chart'
|
||||||
|
|
||||||
type RecentOrder = {
|
type RecentOrder = {
|
||||||
id: string
|
id: string
|
||||||
@@ -34,6 +35,7 @@ const POLL_INTERVAL_MS = 30_000
|
|||||||
|
|
||||||
export function AdminRecentOrders() {
|
export function AdminRecentOrders() {
|
||||||
const [orders, setOrders] = useState<RecentOrder[]>([])
|
const [orders, setOrders] = useState<RecentOrder[]>([])
|
||||||
|
const [chartData, setChartData] = useState<{ name: string; value: number }[]>([])
|
||||||
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
||||||
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
||||||
const knownIds = useRef<Set<string>>(new Set())
|
const knownIds = useRef<Set<string>>(new Set())
|
||||||
@@ -43,7 +45,7 @@ export function AdminRecentOrders() {
|
|||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
|
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { orders: fresh } = await res.json()
|
const { orders: fresh, chartData: freshChart } = await res.json()
|
||||||
|
|
||||||
// Neue Bestellungen erkennen (Highlight)
|
// Neue Bestellungen erkennen (Highlight)
|
||||||
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
||||||
@@ -54,6 +56,7 @@ export function AdminRecentOrders() {
|
|||||||
knownIds.current = freshIds
|
knownIds.current = freshIds
|
||||||
|
|
||||||
setOrders(fresh)
|
setOrders(fresh)
|
||||||
|
setChartData(freshChart || [])
|
||||||
setNewOrderIds(newOnes)
|
setNewOrderIds(newOnes)
|
||||||
setLastChecked(new Date())
|
setLastChecked(new Date())
|
||||||
|
|
||||||
@@ -76,12 +79,10 @@ export function AdminRecentOrders() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
<Card className="col-span-4 glass-dark border-white/5">
|
<Card className="col-span-4 glass-dark border-white/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Übersicht</CardTitle>
|
<CardTitle>Übersicht (letzte 6 Monate)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pl-2">
|
<CardContent className="pl-2">
|
||||||
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
|
<RevenueChart data={chartData} />
|
||||||
[Umsatz-Chart – folgt in nächstem Update]
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
168
shop/components/admin/revenue-chart.tsx
Normal file
168
shop/components/admin/revenue-chart.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type ChartDataPoint = {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueChart({ data }: { data: ChartDataPoint[] }) {
|
||||||
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-slate-500 italic">
|
||||||
|
Keine Umsatzdaten vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 500
|
||||||
|
const height = 200
|
||||||
|
const paddingLeft = 55
|
||||||
|
const paddingRight = 15
|
||||||
|
const paddingTop = 25
|
||||||
|
const paddingBottom = 30
|
||||||
|
|
||||||
|
const chartWidth = width - paddingLeft - paddingRight
|
||||||
|
const chartHeight = height - paddingTop - paddingBottom
|
||||||
|
|
||||||
|
const maxVal = Math.max(...data.map(d => d.value), 100)
|
||||||
|
const maxValue = Math.ceil(maxVal * 1.15) // 15% headroom
|
||||||
|
|
||||||
|
const points = data.map((d, i) => {
|
||||||
|
const x = paddingLeft + (i / (data.length - 1)) * chartWidth
|
||||||
|
const y = paddingTop + chartHeight - (d.value / maxValue) * chartHeight
|
||||||
|
return { x, y, name: d.name, value: d.value }
|
||||||
|
})
|
||||||
|
|
||||||
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||||
|
const areaPath = points.length > 0
|
||||||
|
? `${linePath} L ${points[points.length - 1].x} ${paddingTop + chartHeight} L ${points[0].x} ${paddingTop + chartHeight} Z`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// 4 ticks for grid
|
||||||
|
const yTicks = [0, 0.33, 0.66, 1].map(ratio => Math.round(maxValue * ratio))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto overflow-visible select-none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Grid Lines */}
|
||||||
|
{yTicks.map((val, idx) => {
|
||||||
|
const y = paddingTop + chartHeight - (val / maxValue) * chartHeight
|
||||||
|
return (
|
||||||
|
<g key={idx} className="opacity-40">
|
||||||
|
<line
|
||||||
|
x1={paddingLeft}
|
||||||
|
y1={y}
|
||||||
|
x2={width - paddingRight}
|
||||||
|
y2={y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
className="stroke-white/10"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={paddingLeft - 8}
|
||||||
|
y={y + 4}
|
||||||
|
className="fill-slate-400 text-[9px] text-right font-mono"
|
||||||
|
textAnchor="end"
|
||||||
|
>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(val)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* X Axis Labels */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={p.x}
|
||||||
|
y={height - 8}
|
||||||
|
className="fill-slate-400 text-[10px] font-semibold"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Area under the line */}
|
||||||
|
{areaPath && (
|
||||||
|
<path d={areaPath} fill="url(#areaGrad)" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
{linePath && (
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="drop-shadow-[0_2px_8px_rgba(59,130,246,0.4)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interactive Dots & Hover areas */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
{/* Thick hover circle area */}
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={16}
|
||||||
|
fill="transparent"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onMouseEnter={() => setHoveredIdx(i)}
|
||||||
|
onMouseLeave={() => setHoveredIdx(null)}
|
||||||
|
/>
|
||||||
|
{/* Visual Point dot */}
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={hoveredIdx === i ? 6 : 4}
|
||||||
|
className="transition-all duration-200 fill-primary stroke-[#0b0f19] stroke-2"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
{hoveredIdx === i && (
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={12}
|
||||||
|
className="fill-primary/20 animate-ping"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* HTML Tooltip overlay */}
|
||||||
|
{hoveredIdx !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute bg-slate-950/95 border border-white/10 rounded-lg p-2.5 text-xs text-white shadow-xl backdrop-blur-md pointer-events-none transition-all duration-150 font-sans"
|
||||||
|
style={{
|
||||||
|
left: `${((points[hoveredIdx].x - paddingLeft) / chartWidth) * 80 + 10}%`,
|
||||||
|
top: `${(points[hoveredIdx].y / height) * 75 - 12}%`,
|
||||||
|
transform: 'translateX(-50%) translateY(-100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-slate-400">{points[hoveredIdx].name}</p>
|
||||||
|
<p className="font-bold text-sm text-primary mt-0.5">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(points[hoveredIdx].value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -484,7 +484,10 @@ export function OrderWizard({
|
|||||||
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId}
|
disabled={
|
||||||
|
customerMode === 'create' ||
|
||||||
|
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
|
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user