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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user