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:
@@ -6,6 +6,16 @@ export type WidgetType =
|
||||
| 'RecentRenders'
|
||||
| 'CostOverview'
|
||||
| 'WorkerStatus'
|
||||
| 'KPISummary'
|
||||
| 'OrderThroughput'
|
||||
| 'RevenueChart'
|
||||
| 'ItemStatus'
|
||||
| 'ProcessingTimes'
|
||||
| 'RenderTimeByOutputType'
|
||||
| 'OutputTypeUsage'
|
||||
| 'TopProducts'
|
||||
| 'OrdersByUser'
|
||||
| 'RenderBackendStats'
|
||||
|
||||
export interface WidgetPosition {
|
||||
col: number
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import DashboardGrid from '../components/dashboard/DashboardGrid'
|
||||
import AdminDashboard from '../components/dashboard/AdminDashboard'
|
||||
import ClientDashboard from '../components/dashboard/ClientDashboard'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Configurable widget grid — visible to all roles */}
|
||||
<div className="p-8 pb-0">
|
||||
<h1 className="text-2xl font-bold text-content mb-6">Dashboard</h1>
|
||||
<DashboardGrid />
|
||||
</div>
|
||||
|
||||
{/* Role-based analytics section */}
|
||||
{isPrivileged ? <AdminDashboard /> : <ClientDashboard />}
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold text-content mb-6">Dashboard</h1>
|
||||
<DashboardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user