feat(L1): modular widget dashboard — 15 configurable widgets

Replaces monolithic AdminDashboard/ClientDashboard with a per-user
configurable widget grid. 15 widget types: ProductionStats, QueueStatus,
RecentRenders, CostOverview, WorkerStatus, KPISummary, OrderThroughput,
Revenue, ItemStatus, ProcessingTimes, RenderTimeByOutputType,
OutputTypeUsage, TopProducts, OrdersByUser, RenderBackendStats.

DashboardTimeframeContext provides shared timeframe state. Dashboard
config persisted in DB via GET/PUT /api/dashboard/config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:11:13 +01:00
parent a70cb55d01
commit f15b035b88
19 changed files with 939 additions and 798 deletions
@@ -1,593 +0,0 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, Legend,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { getDashboardKPIs } from '../../api/analytics'
const SCHAEFFLER_GREEN = '#00893d'
const INDIGO = '#6366f1'
const AMBER = '#f59e0b'
const GREEN = '#22c55e'
const RED = '#ef4444'
const BLUE = '#3b82f6'
const PURPLE = '#8b5cf6'
const TEAL = '#14b8a6'
const ROSE = '#f43f5e'
const CYAN = '#06b6d4'
const CATEGORY_COLORS = [SCHAEFFLER_GREEN, INDIGO, AMBER, BLUE, PURPLE, TEAL, ROSE, CYAN]
const CHART_TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
type Preset = '4w' | '3m' | '6m' | '1y' | 'all' | 'custom'
const PRESETS: { key: Preset; label: string }[] = [
{ key: '4w', label: '4 W' },
{ key: '3m', label: '3 M' },
{ key: '6m', label: '6 M' },
{ key: '1y', label: '1 Y' },
{ key: 'all', label: 'All' },
{ key: 'custom', label: 'Custom' },
]
function toISO(d: Date): string {
return d.toISOString().slice(0, 10)
}
function presetRange(key: Preset): { from: string; to: string } | null {
const now = new Date()
const to = toISO(now)
switch (key) {
case '4w': { const d = new Date(now); d.setDate(d.getDate() - 28); return { from: toISO(d), to } }
case '3m': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return { from: toISO(d), to } }
case '6m': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return { from: toISO(d), to } }
case '1y': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return { from: toISO(d), to } }
case 'all': return null // no params → backend defaults omitted, we send nothing
case 'custom': return null
}
}
function presetSubtitle(key: Preset, customFrom: string, customTo: string): string {
switch (key) {
case '4w': return 'Last 4 weeks'
case '3m': return 'Last 3 months'
case '6m': return 'Last 6 months'
case '1y': return 'Last year'
case 'all': return 'All time'
case 'custom': {
if (customFrom && customTo) {
const f = new Date(customFrom + 'T00:00:00')
const t = new Date(customTo + 'T00:00:00')
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
return `${fmt(f)} ${fmt(t)}`
}
return 'Select a date range'
}
}
}
function fmtSeconds(s: number | null | undefined): string {
if (s == null) return '—'
if (s >= 60) return `${(s / 60).toFixed(1)} min`
return `${s.toFixed(1)} s`
}
export default function AdminDashboard() {
const [preset, setPreset] = useState<Preset>('6m')
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const { dateFrom, dateTo } = useMemo(() => {
if (preset === 'custom') {
return { dateFrom: customFrom || undefined, dateTo: customTo || undefined }
}
if (preset === 'all') {
return { dateFrom: '2000-01-01', dateTo: toISO(new Date()) }
}
const range = presetRange(preset)
return range ? { dateFrom: range.from, dateTo: range.to } : { dateFrom: undefined, dateTo: undefined }
}, [preset, customFrom, customTo])
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
function selectPreset(key: Preset) {
setPreset(key)
if (key !== 'custom') {
setCustomFrom('')
setCustomTo('')
}
}
const subtitle = presetSubtitle(preset, customFrom, customTo)
if (isLoading) return <div className="p-8 text-center text-content-muted">Loading analytics</div>
if (error) return <div className="p-8 text-center text-red-500">Failed to load analytics</div>
if (!data) return null
const {
summary, throughput, revenue, processing_times, item_status, render_times,
product_stats, output_type_usage, render_status, renderer_usage,
top_products, orders_by_user, category_revenue, render_backend_stats,
render_time_by_output_type,
} = data
const pieData = [
{ name: 'Pending', value: item_status.pending, color: AMBER },
{ name: 'Approved', value: item_status.approved, color: GREEN },
{ name: 'Rejected', value: item_status.rejected, color: RED },
]
const renderStatusPieData = [
{ name: 'Pending', value: render_status.pending, color: AMBER },
{ name: 'Processing', value: render_status.processing, color: BLUE },
{ name: 'Completed', value: render_status.completed, color: GREEN },
{ name: 'Failed', value: render_status.failed, color: RED },
]
const rendererPieData = renderer_usage.map((r, i) => ({
name: r.renderer || 'unknown',
value: r.count,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
}))
return (
<div className="p-8 space-y-6">
<div className="mb-2">
<h1 className="text-2xl font-bold text-content">Analytics Dashboard</h1>
<p className="text-content-muted mt-1 text-sm">{subtitle} · refreshes every 60 s</p>
</div>
{/* Timeframe selector bar */}
<div className="flex flex-wrap items-center gap-2">
{PRESETS.map(({ key, label }) => (
<button
key={key}
onClick={() => selectPreset(key)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${
preset === key
? 'text-white'
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
}`}
style={preset === key ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
{label}
</button>
))}
{preset === 'custom' && (
<div className="flex items-center gap-2 ml-2">
<input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="border border-border-default rounded px-2 py-1 text-xs"
/>
<span className="text-content-muted text-xs"></span>
<input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="border border-border-default rounded px-2 py-1 text-xs"
/>
</div>
)}
</div>
{/* Row 1 — Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<SummaryCard label="Total Orders" value={summary.total_orders} />
<SummaryCard label="Completed" value={summary.completed_orders} />
<SummaryCard label="Rendering Items" value={summary.total_rendering_items} />
<SummaryCard label="Total Revenue (€)" value={`${summary.total_revenue.toFixed(2)}`} />
<SummaryCard label="Products Rendered" value={product_stats.unique_products_rendered} />
<SummaryCard label="CAD Coverage" value={`${product_stats.products_with_cad} / ${product_stats.total_products}`} />
</div>
{/* Row 2 — Throughput + Item status */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-4 lg:col-span-2">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Order Throughput (weekly)</h2>
{throughput.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={throughput} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="week" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="count" name="Created" stroke={SCHAEFFLER_GREEN} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="completed" name="Completed" stroke={INDIGO} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Item Status</h2>
{pieData.every((d) => d.value === 0) ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{pieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 3 — Revenue + Processing times */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue per Month ()</h2>
{revenue.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="month" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
<Bar dataKey="revenue" fill={SCHAEFFLER_GREEN} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-4">Processing Times</h2>
<table className="w-full text-sm">
<tbody className="divide-y divide-border-light">
<MetricRow label="Avg submit → complete" value={fmtSeconds(processing_times.avg_submit_to_complete_s)} />
<MetricRow label="Avg submit → processing" value={fmtSeconds(processing_times.avg_submit_to_processing_s)} />
<MetricRow label="P50 (median)" value={fmtSeconds(processing_times.p50_s)} />
<MetricRow label="P95" value={fmtSeconds(processing_times.p95_s)} />
</tbody>
</table>
<h2 className="text-sm font-semibold text-content-secondary mt-5 mb-3">Render Time Breakdown</h2>
<table className="w-full text-sm">
<tbody className="divide-y divide-border-light">
<MetricRow label="Avg render time" value={fmtSeconds(render_times.avg_render_s)} />
<MetricRow label="Completed renders" value={String(render_times.sample_count)} />
</tbody>
</table>
</div>
</div>
{/* Row 3b — Render Time by Output Type */}
{render_time_by_output_type && render_time_by_output_type.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-4">Renderzeit pro Output-Typ</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Horizontal bar chart: Avg + P50 per output type */}
<ResponsiveContainer width="100%" height={Math.max(180, render_time_by_output_type.length * 44)}>
<BarChart
data={render_time_by_output_type}
layout="vertical"
margin={{ top: 4, right: 40, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" horizontal={false} />
<XAxis
type="number"
tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }}
tickFormatter={(v: number) => v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`}
/>
<YAxis type="category" dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} width={130} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(v: unknown, name?: string) => {
const n = typeof v === 'number' ? v : null
return [n != null ? (n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(0)} s`) : '—', name ?? '']
}}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Ø Renderzeit" fill={INDIGO} radius={[0, 3, 3, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Detail table */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border-default text-left text-content-muted">
<th className="pb-2 pr-3 font-medium">Output-Typ</th>
<th className="pb-2 px-2 font-medium text-right">Jobs</th>
<th className="pb-2 px-2 font-medium text-right">Ø</th>
<th className="pb-2 px-2 font-medium text-right">P50</th>
<th className="pb-2 px-2 font-medium text-right">Min</th>
<th className="pb-2 pl-2 font-medium text-right">Max</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{render_time_by_output_type.map((r) => (
<tr key={r.output_type} className="hover:bg-surface-hover">
<td className="py-1.5 pr-3 font-medium text-content-secondary max-w-[160px] truncate" title={r.output_type}>
{r.output_type}
</td>
<td className="py-1.5 px-2 text-right text-content-muted">{r.job_count}</td>
<td className="py-1.5 px-2 text-right tabular-nums">{fmtSeconds(r.avg_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.p50_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.min_render_s)}</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.max_render_s)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Row 4 — Output Type Usage + Render Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Output Type Usage</h2>
{output_type_usage.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={output_type_usage} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" fill={INDIGO} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Status</h2>
{renderStatusPieData.every((d) => d.value === 0) ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={renderStatusPieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{renderStatusPieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 5 — Products by Category + Renderer Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Products by Category</h2>
{product_stats.products_by_category.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={product_stats.products_by_category} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{product_stats.products_by_category.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Renderer Usage</h2>
{rendererPieData.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={rendererPieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{rendererPieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 5b — Render Backend Comparison */}
{render_backend_stats.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend Job Count</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="completed" name="Completed" fill={GREEN} radius={[3, 3, 0, 0]} />
<Bar dataKey="failed" name="Failed" fill={RED} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend Avg Time</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} label={{ value: 'seconds', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(1)}s`, ''] : ['—', '']} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Avg" fill={INDIGO} radius={[3, 3, 0, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Row 6 — Top 10 Products + Category Revenue */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Top 10 Products</h2>
{top_products.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium">PIM-ID</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Product</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Category</th>
<th className="py-2 text-content-secondary font-medium text-right">Orders</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{top_products.map((p, i) => (
<tr key={`${p.pim_id}-${i}`}>
<td className="py-1.5 pr-3 font-mono text-xs text-content-muted">{p.pim_id}</td>
<td className="py-1.5 pr-3 text-content truncate max-w-[160px]">{p.product_name || '—'}</td>
<td className="py-1.5 pr-3 text-content-muted">{p.category}</td>
<td className="py-1.5 font-medium text-content text-right">{p.order_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue by Category ()</h2>
{category_revenue.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={category_revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
<Bar dataKey="revenue" radius={[3, 3, 0, 0]}>
{category_revenue.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 7 — Orders by User */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Orders by User</h2>
{orders_by_user.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium">Name</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Email</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Role</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-right">Orders</th>
<th className="py-2 text-content-secondary font-medium text-right">Revenue ()</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{orders_by_user.map((u) => (
<tr key={u.email}>
<td className="py-1.5 pr-3 text-content">{u.full_name}</td>
<td className="py-1.5 pr-3 text-content-muted text-xs">{u.email}</td>
<td className="py-1.5 pr-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-secondary">
{u.role === 'project_manager' ? 'PM' : u.role}
</span>
</td>
<td className="py-1.5 pr-3 font-medium text-content text-right">{u.order_count}</td>
<td className="py-1.5 font-medium text-content text-right"> {u.revenue.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function SummaryCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="card p-5">
<p className="text-2xl font-bold text-content">{value}</p>
<p className="text-sm text-content-muted mt-1">{label}</p>
</div>
)
}
function MetricRow({ label, value }: { label: string; value: string }) {
return (
<tr>
<td className="py-1.5 pr-3 text-content-secondary">{label}</td>
<td className="py-1.5 font-medium text-content text-right">{value}</td>
</tr>
)
}
@@ -1,105 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Package, Upload, CheckCircle, Clock, AlertCircle } from 'lucide-react'
import { listOrders } from '../../api/orders'
import { useAuthStore } from '../../store/auth'
export default function ClientDashboard() {
const user = useAuthStore((s) => s.user)
const { data: orders } = useQuery({ queryKey: ['orders'], queryFn: () => listOrders({ limit: 100 }) })
const stats = {
total: orders?.length ?? 0,
draft: orders?.filter((o) => o.status === 'draft').length ?? 0,
submitted: orders?.filter((o) => o.status === 'submitted').length ?? 0,
completed: orders?.filter((o) => o.status === 'completed').length ?? 0,
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-content">Welcome, {user?.full_name}</h1>
<p className="text-content-muted mt-1">Schaeffler Media Creation Pipeline</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Orders" value={stats.total} icon={Package} color="blue" />
<StatCard label="Drafts" value={stats.draft} icon={Clock} color="yellow" />
<StatCard label="Submitted" value={stats.submitted} icon={AlertCircle} color="orange" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle} color="green" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-6">
<h2 className="font-semibold text-content mb-4">Quick Actions</h2>
<div className="space-y-3">
<Link to="/upload" className="btn-primary w-full justify-center">
<Upload size={16} />
Upload Excel Order List
</Link>
<Link to="/orders" className="btn-secondary w-full justify-center">
<Package size={16} />
View All Orders
</Link>
</div>
</div>
<div className="card p-6">
<h2 className="font-semibold text-content mb-4">Recent Orders</h2>
{orders && orders.length > 0 ? (
<div className="space-y-2">
{orders.slice(0, 5).map((order) => (
<Link
key={order.id}
to={`/orders/${order.id}`}
className="flex items-center justify-between p-2 rounded hover:bg-surface-hover transition-colors"
>
<span className="text-sm font-medium text-content">{order.order_number}</span>
<div className="flex items-center gap-3">
{order.estimated_price != null && (
<span className="text-xs text-content-muted">
{Number(order.estimated_price).toFixed(2)}
</span>
)}
<StatusBadge status={order.status} />
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-content-muted">No orders yet. Upload an Excel file to get started.</p>
)}
</div>
</div>
</div>
)
}
function StatCard({ label, value, icon: Icon, color }: { label: string; value: number; icon: any; color: string }) {
const colors: Record<string, string> = {
blue: 'text-status-info-text bg-status-info-bg',
yellow: 'text-yellow-600 bg-yellow-50',
orange: 'text-status-warning-text bg-status-warning-bg',
green: 'text-status-success-text bg-status-success-bg',
}
return (
<div className="card p-5">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
<Icon size={20} />
</div>
<p className="text-2xl font-bold text-content">{value}</p>
<p className="text-sm text-content-muted mt-1">{label}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'badge-gray',
submitted: 'badge-blue',
processing: 'badge-yellow',
completed: 'badge-green',
rejected: 'badge-red',
}
return <span className={map[status] ?? 'badge-gray'}>{status}</span>
}
@@ -6,14 +6,24 @@ import { updateDashboardConfig, updateTenantDefaultDashboard } from '../../api/d
import type { WidgetConfig, WidgetType } from '../../api/dashboard'
const WIDGET_LABELS: Record<WidgetType, string> = {
ProductionStats: 'Production Stats',
QueueStatus: 'Queue Status',
RecentRenders: 'Recent Renders',
CostOverview: 'Cost Overview',
WorkerStatus: 'Worker Status',
ProductionStats: 'Production Stats',
QueueStatus: 'Queue Status',
RecentRenders: 'Recent Renders',
CostOverview: 'Cost Overview',
WorkerStatus: 'Worker Status',
KPISummary: 'KPI Summary',
OrderThroughput: 'Order Throughput',
RevenueChart: 'Revenue',
ItemStatus: 'Item & Render Status',
ProcessingTimes: 'Processing Times',
RenderTimeByOutputType: 'Render Time by Output Type',
OutputTypeUsage: 'Output Type Usage',
TopProducts: 'Top 10 Products',
OrdersByUser: 'Orders by User',
RenderBackendStats: 'Render Backend Stats',
}
const ALL_WIDGET_TYPES: WidgetType[] = [
const OPERATIONAL_TYPES: WidgetType[] = [
'ProductionStats',
'QueueStatus',
'RecentRenders',
@@ -21,8 +31,22 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
'WorkerStatus',
]
const ANALYTICS_TYPES: WidgetType[] = [
'KPISummary',
'OrderThroughput',
'RevenueChart',
'ItemStatus',
'ProcessingTimes',
'RenderTimeByOutputType',
'OutputTypeUsage',
'TopProducts',
'OrdersByUser',
'RenderBackendStats',
]
const ALL_WIDGET_TYPES: WidgetType[] = [...OPERATIONAL_TYPES, ...ANALYTICS_TYPES]
function recalculatePositions(widgets: WidgetConfig[]): WidgetConfig[] {
// Re-layout: 3 columns, each widget w=1 h=1, fill left-to-right top-to-bottom
return widgets.map((w, i) => ({
...w,
position: {
@@ -48,21 +72,19 @@ export default function DashboardCustomizeModal({
}: Props) {
const qc = useQueryClient()
// Track which widget types are enabled
const [enabled, setEnabled] = useState<Set<WidgetType>>(
new Set(currentWidgets.map((w) => w.widget_type as WidgetType))
)
const saveMut = useMutation({
mutationFn: async () => {
// Build widget list from enabled set, preserving existing configs
const selected = ALL_WIDGET_TYPES.filter((t) => enabled.has(t))
const configMap = new Map(
currentWidgets.map((w) => [w.widget_type, w.config])
)
const newWidgets: WidgetConfig[] = selected.map((t) => ({
widget_type: t,
position: { col: 0, row: 0, w: 1, h: 1 }, // recalculated below
position: { col: 0, row: 0, w: 1, h: 1 },
config: configMap.get(t),
}))
const layouted = recalculatePositions(newWidgets)
@@ -94,37 +116,15 @@ export default function DashboardCustomizeModal({
})
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<div
className="rounded-xl shadow-xl w-full max-w-md mx-4"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default">
<h2 className="font-semibold text-content">
{tenantMode ? 'Edit Tenant Default Dashboard' : 'Customize Dashboard'}
</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content transition-colors"
>
<X size={18} />
</button>
</div>
{/* Widget list */}
<div className="px-5 py-4 space-y-2">
<p className="text-xs text-content-muted mb-3">
Select which widgets are visible on the dashboard.
</p>
{ALL_WIDGET_TYPES.map((type) => (
function renderGroup(title: string, types: WidgetType[]) {
return (
<div>
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide mb-2 px-1">{title}</p>
<div className="space-y-1.5">
{types.map((type) => (
<label
key={type}
className="flex items-center gap-3 cursor-pointer rounded-lg border border-border-default px-4 py-3 hover:border-accent transition-colors"
className="flex items-center gap-3 cursor-pointer rounded-lg border border-border-default px-4 py-2.5 hover:border-accent transition-colors"
>
<input
type="checkbox"
@@ -138,9 +138,43 @@ export default function DashboardCustomizeModal({
</label>
))}
</div>
</div>
)
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<div
className="rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[90vh] flex flex-col"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default flex-shrink-0">
<h2 className="font-semibold text-content">
{tenantMode ? 'Edit Tenant Default Dashboard' : 'Customize Dashboard'}
</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content transition-colors"
>
<X size={18} />
</button>
</div>
{/* Widget list */}
<div className="px-5 py-4 space-y-4 overflow-y-auto">
<p className="text-xs text-content-muted">
Select which widgets are visible on the dashboard.
</p>
{renderGroup('Operational', OPERATIONAL_TYPES)}
{renderGroup('Analytics', ANALYTICS_TYPES)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-border-default">
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-border-default flex-shrink-0">
<button onClick={onClose} className="btn-secondary text-sm">
Cancel
</button>
@@ -1,8 +1,13 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Settings2, BarChart2, Activity, ImageIcon, DollarSign, Cpu } from 'lucide-react'
import {
Settings2, BarChart2, Activity, ImageIcon, DollarSign, Cpu,
TrendingUp, LineChart, PieChart, Clock, LayoutGrid, Table2, Users, Server,
} from 'lucide-react'
import { getDashboardConfig } from '../../api/dashboard'
import type { WidgetType } from '../../api/dashboard'
import { DashboardTimeframeProvider, useDashboardTimeframe } from './DashboardTimeframeContext'
import type { Preset } from './DashboardTimeframeContext'
import WidgetContainer from './WidgetContainer'
import DashboardCustomizeModal from './DashboardCustomizeModal'
import ProductionStatsWidget from './widgets/ProductionStatsWidget'
@@ -10,27 +15,115 @@ import QueueStatusWidget from './widgets/QueueStatusWidget'
import RecentRendersWidget from './widgets/RecentRendersWidget'
import CostOverviewWidget from './widgets/CostOverviewWidget'
import WorkerStatusWidget from './widgets/WorkerStatusWidget'
import KPISummaryWidget from './widgets/KPISummaryWidget'
import OrderThroughputWidget from './widgets/OrderThroughputWidget'
import RevenueChartWidget from './widgets/RevenueChartWidget'
import ItemStatusWidget from './widgets/ItemStatusWidget'
import ProcessingTimesWidget from './widgets/ProcessingTimesWidget'
import RenderTimeByOutputTypeWidget from './widgets/RenderTimeByOutputTypeWidget'
import OutputTypeUsageWidget from './widgets/OutputTypeUsageWidget'
import TopProductsWidget from './widgets/TopProductsWidget'
import OrdersByUserWidget from './widgets/OrdersByUserWidget'
import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget'
const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }> = {
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
QueueStatus: { title: 'Queue Status', icon: <Activity size={15} /> },
RecentRenders: { title: 'Recent Renders', icon: <ImageIcon size={15} /> },
CostOverview: { title: 'Cost Overview', icon: <DollarSign size={15} /> },
WorkerStatus: { title: 'Worker Status', icon: <Cpu size={15} /> },
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
QueueStatus: { title: 'Queue Status', icon: <Activity size={15} /> },
RecentRenders: { title: 'Recent Renders', icon: <ImageIcon size={15} /> },
CostOverview: { title: 'Cost Overview', icon: <DollarSign size={15} /> },
WorkerStatus: { title: 'Worker Status', icon: <Cpu size={15} /> },
KPISummary: { title: 'KPI Summary', icon: <TrendingUp size={15} /> },
OrderThroughput: { title: 'Order Throughput', icon: <LineChart size={15} /> },
RevenueChart: { title: 'Revenue', icon: <DollarSign size={15} /> },
ItemStatus: { title: 'Item & Render Status', icon: <PieChart size={15} /> },
ProcessingTimes: { title: 'Processing Times', icon: <Clock size={15} /> },
RenderTimeByOutputType: { title: 'Render Time by Output Type', icon: <BarChart2 size={15} /> },
OutputTypeUsage: { title: 'Output Type Usage', icon: <LayoutGrid size={15} /> },
TopProducts: { title: 'Top 10 Products', icon: <Table2 size={15} /> },
OrdersByUser: { title: 'Orders by User', icon: <Users size={15} /> },
RenderBackendStats: { title: 'Render Backend Stats', icon: <Server size={15} /> },
}
// Analytics widget types that need a timeframe
const ANALYTICS_WIDGET_TYPES = new Set<WidgetType>([
'ProductionStats', 'KPISummary', 'OrderThroughput', 'RevenueChart',
'ItemStatus', 'ProcessingTimes', 'RenderTimeByOutputType',
'OutputTypeUsage', 'TopProducts', 'OrdersByUser', 'RenderBackendStats',
])
const PRESETS: { key: Preset; label: string }[] = [
{ key: '4w', label: '4 W' },
{ key: '3m', label: '3 M' },
{ key: '6m', label: '6 M' },
{ key: '1y', label: '1 Y' },
{ key: 'all', label: 'All' },
{ key: 'custom', label: 'Custom' },
]
function WidgetBody({ type }: { type: WidgetType }) {
switch (type) {
case 'ProductionStats': return <ProductionStatsWidget />
case 'QueueStatus': return <QueueStatusWidget />
case 'RecentRenders': return <RecentRendersWidget />
case 'CostOverview': return <CostOverviewWidget />
case 'WorkerStatus': return <WorkerStatusWidget />
default: return <p className="text-xs text-content-muted">Unknown widget</p>
case 'ProductionStats': return <ProductionStatsWidget />
case 'QueueStatus': return <QueueStatusWidget />
case 'RecentRenders': return <RecentRendersWidget />
case 'CostOverview': return <CostOverviewWidget />
case 'WorkerStatus': return <WorkerStatusWidget />
case 'KPISummary': return <KPISummaryWidget />
case 'OrderThroughput': return <OrderThroughputWidget />
case 'RevenueChart': return <RevenueChartWidget />
case 'ItemStatus': return <ItemStatusWidget />
case 'ProcessingTimes': return <ProcessingTimesWidget />
case 'RenderTimeByOutputType': return <RenderTimeByOutputTypeWidget />
case 'OutputTypeUsage': return <OutputTypeUsageWidget />
case 'TopProducts': return <TopProductsWidget />
case 'OrdersByUser': return <OrdersByUserWidget />
case 'RenderBackendStats': return <RenderBackendStatsWidget />
default: return <p className="text-xs text-content-muted">Unknown widget</p>
}
}
export default function DashboardGrid() {
function TimeframeSelector({ widgets }: { widgets: WidgetType[] }) {
const { preset, customFrom, customTo, setPreset, setCustomFrom, setCustomTo } = useDashboardTimeframe()
const hasAnalytics = widgets.some((t) => ANALYTICS_WIDGET_TYPES.has(t))
if (!hasAnalytics) return null
return (
<div className="flex flex-wrap items-center gap-2">
{PRESETS.map(({ key, label }) => (
<button
key={key}
onClick={() => setPreset(key)}
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
preset === key
? 'text-white'
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
}`}
style={preset === key ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
{label}
</button>
))}
{preset === 'custom' && (
<div className="flex items-center gap-2 ml-1">
<input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="border border-border-default rounded px-2 py-0.5 text-xs"
/>
<span className="text-content-muted text-xs"></span>
<input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="border border-border-default rounded px-2 py-0.5 text-xs"
/>
</div>
)}
</div>
)
}
function DashboardGridInner() {
const [showCustomize, setShowCustomize] = useState(false)
const { data: widgets, isLoading } = useQuery({
@@ -39,13 +132,16 @@ export default function DashboardGrid() {
staleTime: 300_000,
})
const widgetTypes = (widgets ?? []).map((w) => w.widget_type as WidgetType)
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-end">
<div className="flex flex-wrap items-center gap-3 justify-between">
<TimeframeSelector widgets={widgetTypes} />
<button
onClick={() => setShowCustomize(true)}
className="btn-secondary text-sm flex items-center gap-1.5"
className="btn-secondary text-sm flex items-center gap-1.5 ml-auto"
>
<Settings2 size={14} />
Anpassen
@@ -103,3 +199,11 @@ export default function DashboardGrid() {
</div>
)
}
export default function DashboardGrid() {
return (
<DashboardTimeframeProvider>
<DashboardGridInner />
</DashboardTimeframeProvider>
)
}
@@ -0,0 +1,67 @@
import { createContext, useContext, useState, useMemo } from 'react'
export type Preset = '4w' | '3m' | '6m' | '1y' | 'all' | 'custom'
interface TimeframeCtx {
preset: Preset
dateFrom: string | undefined
dateTo: string | undefined
customFrom: string
customTo: string
setPreset: (p: Preset) => void
setCustomFrom: (v: string) => void
setCustomTo: (v: string) => void
}
const Ctx = createContext<TimeframeCtx | null>(null)
export function useDashboardTimeframe() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useDashboardTimeframe must be inside DashboardTimeframeProvider')
return ctx
}
function toISO(d: Date) {
return d.toISOString().slice(0, 10)
}
function presetDates(p: Preset): { from: string | undefined; to: string | undefined } {
const now = new Date()
const to = toISO(now)
switch (p) {
case '4w': { const d = new Date(now); d.setDate(d.getDate() - 28); return { from: toISO(d), to } }
case '3m': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return { from: toISO(d), to } }
case '6m': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return { from: toISO(d), to } }
case '1y': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return { from: toISO(d), to } }
case 'all': return { from: '2000-01-01', to }
case 'custom': return { from: undefined, to: undefined }
}
}
export function DashboardTimeframeProvider({ children }: { children: React.ReactNode }) {
const [preset, setPresetState] = useState<Preset>('6m')
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
function setPreset(p: Preset) {
setPresetState(p)
if (p !== 'custom') {
setCustomFrom('')
setCustomTo('')
}
}
const { dateFrom, dateTo } = useMemo(() => {
if (preset === 'custom') {
return { dateFrom: customFrom || undefined, dateTo: customTo || undefined }
}
const r = presetDates(preset)
return { dateFrom: r.from, dateTo: r.to }
}, [preset, customFrom, customTo])
return (
<Ctx.Provider value={{ preset, dateFrom, dateTo, customFrom, customTo, setPreset, setCustomFrom, setCustomTo }}>
{children}
</Ctx.Provider>
)
}
@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, PieChart, Pie, Cell, Legend, Tooltip,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
export default function ItemStatusWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="space-y-3"><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /></div>
if (error) return <p className="text-xs text-red-500">Failed to load item status</p>
if (!data) return null
const itemPie = [
{ name: 'Pending', value: data.item_status.pending, color: '#f59e0b' },
{ name: 'Approved', value: data.item_status.approved, color: '#22c55e' },
{ name: 'Rejected', value: data.item_status.rejected, color: '#ef4444' },
]
const renderPie = [
{ name: 'Pending', value: data.render_status.pending, color: '#f59e0b' },
{ name: 'Processing', value: data.render_status.processing, color: '#3b82f6' },
{ name: 'Completed', value: data.render_status.completed, color: '#22c55e' },
{ name: 'Failed', value: data.render_status.failed, color: '#ef4444' },
]
const noItemData = itemPie.every((d) => d.value === 0)
const noRenderData = renderPie.every((d) => d.value === 0)
return (
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-content-secondary mb-1">Item Status</p>
{noItemData ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={itemPie} dataKey="value" nameKey="name" cx="50%" cy="45%" outerRadius={55}
label={({ name, value }) => `${name}: ${value}`} labelLine={false}>
{itemPie.map((e) => <Cell key={e.name} fill={e.color} />)}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
<div>
<p className="text-xs font-medium text-content-secondary mb-1">Render Status</p>
{noRenderData ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={renderPie} dataKey="value" nameKey="name" cx="50%" cy="45%" outerRadius={55}
label={({ name, value }) => `${name}: ${value}`} labelLine={false}>
{renderPie.map((e) => <Cell key={e.name} fill={e.color} />)}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
)
}
@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
function Skeleton() {
return (
<div className="animate-pulse grid grid-cols-2 gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-16 rounded-lg bg-surface-muted" />
))}
</div>
)
}
export default function KPISummaryWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <Skeleton />
if (error) return <p className="text-xs text-red-500">Failed to load KPIs</p>
if (!data) return null
const cards = [
{ label: 'Total Orders', value: data.summary.total_orders },
{ label: 'Completed', value: data.summary.completed_orders },
{ label: 'Rendering Items', value: data.summary.total_rendering_items },
{ label: 'Revenue', value: `${data.summary.total_revenue.toFixed(2)}` },
{ label: 'Rendered Products', value: data.product_stats.unique_products_rendered },
{ label: 'CAD Coverage', value: `${data.product_stats.products_with_cad} / ${data.product_stats.total_products}` },
]
return (
<div className="grid grid-cols-2 gap-2">
{cards.map(({ label, value }) => (
<div
key={label}
className="rounded-lg border border-border-default p-3"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<p className="text-lg font-bold text-content">{value}</p>
<p className="text-xs text-content-muted mt-0.5">{label}</p>
</div>
))}
</div>
)
}
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
export default function OrderThroughputWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="h-52 animate-pulse rounded-lg bg-surface-muted" />
if (error) return <p className="text-xs text-red-500">Failed to load throughput</p>
if (!data || data.throughput.length === 0) {
return <p className="text-xs text-content-muted text-center py-8">No data yet</p>
}
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data.throughput} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="week" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="count" name="Created" stroke="#00893d" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="completed" name="Completed" stroke="#6366f1" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)
}
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
export default function OrdersByUserWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="h-52 animate-pulse rounded-lg bg-surface-muted" />
if (error) return <p className="text-xs text-red-500">Failed to load user data</p>
if (!data || data.orders_by_user.length === 0) {
return <p className="text-xs text-content-muted text-center py-8">No data yet</p>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">Name</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">Email</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">Role</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-right text-xs">Orders</th>
<th className="py-2 text-content-secondary font-medium text-right text-xs">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{data.orders_by_user.map((u) => (
<tr key={u.email} className="hover:bg-surface-hover">
<td className="py-1.5 pr-3 text-xs text-content">{u.full_name}</td>
<td className="py-1.5 pr-3 text-xs text-content-muted">{u.email}</td>
<td className="py-1.5 pr-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-secondary">
{u.role === 'project_manager' ? 'PM' : u.role}
</span>
</td>
<td className="py-1.5 pr-3 font-medium text-xs text-content text-right">{u.order_count}</td>
<td className="py-1.5 font-medium text-xs text-content text-right"> {u.revenue.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,74 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, Tooltip,
XAxis, YAxis, CartesianGrid,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
const COLORS = ['#00893d', '#6366f1', '#f59e0b', '#3b82f6', '#8b5cf6', '#14b8a6', '#f43f5e', '#06b6d4']
export default function OutputTypeUsageWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="space-y-3"><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /></div>
if (error) return <p className="text-xs text-red-500">Failed to load usage data</p>
if (!data) return null
const rendererPie = data.renderer_usage.map((r, i) => ({
name: r.renderer || 'unknown',
value: r.count,
color: COLORS[i % COLORS.length],
}))
return (
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Output Type Usage</p>
{data.output_type_usage.length === 0 ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={data.output_type_usage} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar dataKey="count" fill="#6366f1" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Renderer Usage</p>
{rendererPie.length === 0 ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={rendererPie} dataKey="value" nameKey="name" cx="50%" cy="45%" outerRadius={55}
label={({ name, value }) => `${name}: ${value}`} labelLine={false}>
{rendererPie.map((e) => <Cell key={e.name} fill={e.color} />)}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import { useQuery } from '@tanstack/react-query'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
function fmtSeconds(s: number | null | undefined): string {
if (s == null) return '—'
if (s >= 60) return `${(s / 60).toFixed(1)} min`
return `${s.toFixed(1)} s`
}
function Row({ label, value }: { label: string; value: string }) {
return (
<tr>
<td className="py-1.5 pr-3 text-content-secondary text-xs">{label}</td>
<td className="py-1.5 font-medium text-content text-right text-xs tabular-nums">{value}</td>
</tr>
)
}
export default function ProcessingTimesWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="space-y-2"><div className="h-24 animate-pulse rounded-lg bg-surface-muted" /><div className="h-16 animate-pulse rounded-lg bg-surface-muted" /></div>
if (error) return <p className="text-xs text-red-500">Failed to load timing data</p>
if (!data) return null
const { processing_times: pt, render_times: rt } = data
return (
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary mb-2">Processing Times</p>
<table className="w-full">
<tbody className="divide-y divide-border-light">
<Row label="Avg submit → complete" value={fmtSeconds(pt.avg_submit_to_complete_s)} />
<Row label="Avg submit → processing" value={fmtSeconds(pt.avg_submit_to_processing_s)} />
<Row label="P50 (median)" value={fmtSeconds(pt.p50_s)} />
<Row label="P95" value={fmtSeconds(pt.p95_s)} />
</tbody>
</table>
</div>
<div>
<p className="text-xs font-semibold text-content-secondary mb-2">Render Time Summary</p>
<table className="w-full">
<tbody className="divide-y divide-border-light">
<Row label="Avg render time" value={fmtSeconds(rt.avg_render_s)} />
<Row label="Completed renders" value={String(rt.sample_count)} />
</tbody>
</table>
</div>
</div>
)
}
@@ -1,12 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { PackageCheck, Clock, CheckCircle2 } from 'lucide-react'
import api from '../../../api/client'
interface OrderStats {
total_orders: number
completed_orders: number
total_rendering_items: number
}
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
function Skeleton() {
return (
@@ -19,20 +14,10 @@ function Skeleton() {
}
export default function ProductionStatsWidget() {
const { data, isLoading, error } = useQuery<OrderStats>({
queryKey: ['production-stats-widget'],
queryFn: async () => {
const today = new Date().toISOString().slice(0, 10)
const res = await api.get('/analytics/kpis', {
params: { date_from: today, date_to: today },
})
const s = res.data?.summary ?? {}
return {
total_orders: s.total_orders ?? 0,
completed_orders: s.completed_orders ?? 0,
total_rendering_items: s.total_rendering_items ?? 0,
}
},
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
retry: 1,
})
@@ -44,10 +29,11 @@ export default function ProductionStatsWidget() {
)
}
const s = data?.summary
const stats = [
{ label: 'Open Orders', value: (data?.total_orders ?? 0) - (data?.completed_orders ?? 0), icon: <Clock size={16} className="text-amber-500" /> },
{ label: 'Completed Orders', value: data?.completed_orders ?? 0, icon: <CheckCircle2 size={16} className="text-green-500" /> },
{ label: 'Rendering Items', value: data?.total_rendering_items ?? 0, icon: <PackageCheck size={16} className="text-blue-500" /> },
{ label: 'Open Orders', value: (s?.total_orders ?? 0) - (s?.completed_orders ?? 0), icon: <Clock size={16} className="text-amber-500" /> },
{ label: 'Completed Orders', value: s?.completed_orders ?? 0, icon: <CheckCircle2 size={16} className="text-green-500" /> },
{ label: 'Rendering Items', value: s?.total_rendering_items ?? 0, icon: <PackageCheck size={16} className="text-blue-500" /> },
]
return (
@@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
export default function RenderBackendStatsWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="space-y-3"><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /></div>
if (error) return <p className="text-xs text-red-500">Failed to load backend stats</p>
if (!data || data.render_backend_stats.length === 0) {
return <p className="text-xs text-content-muted text-center py-8">No data yet</p>
}
return (
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Job Count by Backend</p>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={data.render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="completed" name="Completed" fill="#22c55e" radius={[3, 3, 0, 0]} />
<Bar dataKey="failed" name="Failed" fill="#ef4444" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Avg Render Time (s)</p>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={data.render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number | undefined) => v != null ? [`${v.toFixed(1)} s`, ''] : ['—', '']}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Avg" fill="#6366f1" radius={[3, 3, 0, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill="#14b8a6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
function fmtSeconds(s: number | null | undefined): string {
if (s == null) return '—'
if (s >= 60) return `${(s / 60).toFixed(1)} min`
return `${s.toFixed(0)} s`
}
export default function RenderTimeByOutputTypeWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="h-52 animate-pulse rounded-lg bg-surface-muted" />
if (error) return <p className="text-xs text-red-500">Failed to load render times</p>
if (!data || !data.render_time_by_output_type || data.render_time_by_output_type.length === 0) {
return <p className="text-xs text-content-muted text-center py-8">No data yet</p>
}
const rows = data.render_time_by_output_type
const chartHeight = Math.max(160, rows.length * 44)
return (
<div className="space-y-4">
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 40, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" horizontal={false} />
<XAxis
type="number"
tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }}
tickFormatter={(v: number) => v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`}
/>
<YAxis type="category" dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} width={130} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: unknown, name?: string) => {
const n = typeof v === 'number' ? v : null
return [n != null ? (n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(0)} s`) : '—', name ?? '']
}}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Ø Renderzeit" fill="#6366f1" radius={[0, 3, 3, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill="#14b8a6" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border-default text-left text-content-muted">
<th className="pb-2 pr-3 font-medium">Output-Typ</th>
<th className="pb-2 px-2 font-medium text-right">Jobs</th>
<th className="pb-2 px-2 font-medium text-right">Ø</th>
<th className="pb-2 px-2 font-medium text-right">P50</th>
<th className="pb-2 px-2 font-medium text-right">Min</th>
<th className="pb-2 pl-2 font-medium text-right">Max</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((r) => (
<tr key={r.output_type} className="hover:bg-surface-hover">
<td className="py-1.5 pr-3 font-medium text-content-secondary max-w-[160px] truncate" title={r.output_type}>{r.output_type}</td>
<td className="py-1.5 px-2 text-right text-content-muted">{r.job_count}</td>
<td className="py-1.5 px-2 text-right tabular-nums">{fmtSeconds(r.avg_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.p50_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.min_render_s)}</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.max_render_s)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer, BarChart, Bar, Cell,
XAxis, YAxis, CartesianGrid, Tooltip,
} from 'recharts'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
const TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
const CATEGORY_COLORS = ['#00893d', '#6366f1', '#f59e0b', '#3b82f6', '#8b5cf6', '#14b8a6', '#f43f5e', '#06b6d4']
export default function RevenueChartWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="space-y-3"><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /><div className="h-40 animate-pulse rounded-lg bg-surface-muted" /></div>
if (error) return <p className="text-xs text-red-500">Failed to load revenue</p>
if (!data) return null
return (
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Revenue per Month ()</p>
{data.revenue.length === 0 ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={data.revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="month" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number | undefined) =>
v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']
}
/>
<Bar dataKey="revenue" fill="#00893d" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Revenue by Category ()</p>
{data.category_revenue.length === 0 ? (
<p className="text-xs text-content-muted text-center py-4">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={data.category_revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number | undefined) =>
v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']
}
/>
<Bar dataKey="revenue" radius={[3, 3, 0, 0]}>
{data.category_revenue.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query'
import { getDashboardKPIs } from '../../../api/analytics'
import { useDashboardTimeframe } from '../DashboardTimeframeContext'
export default function TopProductsWidget() {
const { dateFrom, dateTo } = useDashboardTimeframe()
const { data, isLoading, error } = useQuery({
queryKey: ['analytics-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
if (isLoading) return <div className="h-52 animate-pulse rounded-lg bg-surface-muted" />
if (error) return <p className="text-xs text-red-500">Failed to load top products</p>
if (!data || data.top_products.length === 0) {
return <p className="text-xs text-content-muted text-center py-8">No data yet</p>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">PIM-ID</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">Product</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-xs">Category</th>
<th className="py-2 text-content-secondary font-medium text-right text-xs">Orders</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{data.top_products.map((p, i) => (
<tr key={`${p.pim_id}-${i}`} className="hover:bg-surface-hover">
<td className="py-1.5 pr-3 font-mono text-xs text-content-muted">{p.pim_id}</td>
<td className="py-1.5 pr-3 text-xs text-content truncate max-w-[140px]">{p.product_name || '—'}</td>
<td className="py-1.5 pr-3 text-xs text-content-muted">{p.category}</td>
<td className="py-1.5 font-medium text-xs text-content text-right">{p.order_count}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}