From f15b035b8864c57908dc7f4e447eb62c895dd3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 6 Mar 2026 23:11:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(L1):=20modular=20widget=20dashboard=20?= =?UTF-8?q?=E2=80=94=2015=20configurable=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/domains/admin/dashboard_service.py | 24 +- frontend/src/api/dashboard.ts | 10 + .../components/dashboard/AdminDashboard.tsx | 593 ------------------ .../components/dashboard/ClientDashboard.tsx | 105 ---- .../dashboard/DashboardCustomizeModal.tsx | 114 ++-- .../components/dashboard/DashboardGrid.tsx | 134 +++- .../dashboard/DashboardTimeframeContext.tsx | 67 ++ .../dashboard/widgets/ItemStatusWidget.tsx | 82 +++ .../dashboard/widgets/KPISummaryWidget.tsx | 50 ++ .../widgets/OrderThroughputWidget.tsx | 43 ++ .../dashboard/widgets/OrdersByUserWidget.tsx | 49 ++ .../widgets/OutputTypeUsageWidget.tsx | 74 +++ .../widgets/ProcessingTimesWidget.tsx | 59 ++ .../widgets/ProductionStatsWidget.tsx | 34 +- .../widgets/RenderBackendStatsWidget.tsx | 66 ++ .../widgets/RenderTimeByOutputTypeWidget.tsx | 91 +++ .../dashboard/widgets/RevenueChartWidget.tsx | 81 +++ .../dashboard/widgets/TopProductsWidget.tsx | 43 ++ frontend/src/pages/Dashboard.tsx | 18 +- 19 files changed, 939 insertions(+), 798 deletions(-) delete mode 100644 frontend/src/components/dashboard/AdminDashboard.tsx delete mode 100644 frontend/src/components/dashboard/ClientDashboard.tsx create mode 100644 frontend/src/components/dashboard/DashboardTimeframeContext.tsx create mode 100644 frontend/src/components/dashboard/widgets/ItemStatusWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/KPISummaryWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/OrderThroughputWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/OrdersByUserWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/OutputTypeUsageWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/ProcessingTimesWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/RenderBackendStatsWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/RenderTimeByOutputTypeWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/RevenueChartWidget.tsx create mode 100644 frontend/src/components/dashboard/widgets/TopProductsWidget.tsx diff --git a/backend/app/domains/admin/dashboard_service.py b/backend/app/domains/admin/dashboard_service.py index bebfa30..a1943e6 100644 --- a/backend/app/domains/admin/dashboard_service.py +++ b/backend/app/domains/admin/dashboard_service.py @@ -24,15 +24,27 @@ WIDGET_TYPES = ( "RecentRenders", "CostOverview", "WorkerStatus", + "KPISummary", + "OrderThroughput", + "RevenueChart", + "ItemStatus", + "ProcessingTimes", + "RenderTimeByOutputType", + "OutputTypeUsage", + "TopProducts", + "OrdersByUser", + "RenderBackendStats", ) # Default layouts per role _DEFAULT_ADMIN_WIDGETS: list[dict] = [ - {"widget_type": "ProductionStats", "position": {"col": 0, "row": 0, "w": 1, "h": 1}}, - {"widget_type": "QueueStatus", "position": {"col": 1, "row": 0, "w": 1, "h": 1}}, - {"widget_type": "WorkerStatus", "position": {"col": 2, "row": 0, "w": 1, "h": 1}}, - {"widget_type": "RecentRenders", "position": {"col": 0, "row": 1, "w": 2, "h": 1}}, - {"widget_type": "CostOverview", "position": {"col": 2, "row": 1, "w": 1, "h": 1}}, + {"widget_type": "KPISummary", "position": {"col": 0, "row": 0, "w": 1, "h": 1}}, + {"widget_type": "QueueStatus", "position": {"col": 1, "row": 0, "w": 1, "h": 1}}, + {"widget_type": "WorkerStatus", "position": {"col": 2, "row": 0, "w": 1, "h": 1}}, + {"widget_type": "OrderThroughput","position": {"col": 0, "row": 1, "w": 2, "h": 1}}, + {"widget_type": "ItemStatus", "position": {"col": 2, "row": 1, "w": 1, "h": 1}}, + {"widget_type": "RevenueChart", "position": {"col": 0, "row": 2, "w": 2, "h": 1}}, + {"widget_type": "ProcessingTimes","position": {"col": 2, "row": 2, "w": 1, "h": 1}}, ] _DEFAULT_CLIENT_WIDGETS: list[dict] = [ @@ -44,7 +56,7 @@ _DEFAULT_CLIENT_WIDGETS: list[dict] = [ def get_default_widgets_for_role(role: str) -> list[dict]: """Return systemwide default widget layout for a given role. - admin / project_manager: all 5 widget types. + admin / project_manager: KPI + analytics defaults. client: RecentRenders + ProductionStats only. """ if role in ("admin", "project_manager"): diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 682576f..d778eb0 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -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 diff --git a/frontend/src/components/dashboard/AdminDashboard.tsx b/frontend/src/components/dashboard/AdminDashboard.tsx deleted file mode 100644 index 42cc1af..0000000 --- a/frontend/src/components/dashboard/AdminDashboard.tsx +++ /dev/null @@ -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('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
Loading analytics…
- if (error) return
Failed to load analytics
- 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 ( -
-
-

Analytics Dashboard

-

{subtitle} · refreshes every 60 s

-
- - {/* Timeframe selector bar */} -
- {PRESETS.map(({ key, label }) => ( - - ))} - - {preset === 'custom' && ( -
- setCustomFrom(e.target.value)} - className="border border-border-default rounded px-2 py-1 text-xs" - /> - - setCustomTo(e.target.value)} - className="border border-border-default rounded px-2 py-1 text-xs" - /> -
- )} -
- - {/* Row 1 — Summary cards */} -
- - - - - - -
- - {/* Row 2 — Throughput + Item status */} -
-
-

Order Throughput (weekly)

- {throughput.length === 0 ? ( -

No data yet

- ) : ( - - - - - - - - - - - - )} -
- -
-

Item Status

- {pieData.every((d) => d.value === 0) ? ( -

No data yet

- ) : ( - - - `${name}: ${value}`} - labelLine={false} - > - {pieData.map((entry) => ( - - ))} - - - - - - )} -
-
- - {/* Row 3 — Revenue + Processing times */} -
-
-

Revenue per Month (€)

- {revenue.length === 0 ? ( -

No data yet

- ) : ( - - - - - - v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} /> - - - - )} -
- -
-

Processing Times

- - - - - - - -
- -

Render Time Breakdown

- - - - - -
-
-
- - {/* Row 3b — Render Time by Output Type */} - {render_time_by_output_type && render_time_by_output_type.length > 0 && ( -
-

Renderzeit pro Output-Typ

-
- {/* Horizontal bar chart: Avg + P50 per output type */} - - - - v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`} - /> - - { - const n = typeof v === 'number' ? v : null - return [n != null ? (n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(0)} s`) : '—', name ?? ''] - }} - /> - - - - - - - {/* Detail table */} -
- - - - - - - - - - - - - {render_time_by_output_type.map((r) => ( - - - - - - - - - ))} - -
Output-TypJobsØP50MinMax
- {r.output_type} - {r.job_count}{fmtSeconds(r.avg_render_s)}{fmtSeconds(r.p50_render_s)}{fmtSeconds(r.min_render_s)}{fmtSeconds(r.max_render_s)}
-
-
-
- )} - - {/* Row 4 — Output Type Usage + Render Status */} -
-
-

Output Type Usage

- {output_type_usage.length === 0 ? ( -

No data yet

- ) : ( - - - - - - - - - - )} -
- -
-

Render Status

- {renderStatusPieData.every((d) => d.value === 0) ? ( -

No data yet

- ) : ( - - - `${name}: ${value}`} - labelLine={false} - > - {renderStatusPieData.map((entry) => ( - - ))} - - - - - - )} -
-
- - {/* Row 5 — Products by Category + Renderer Usage */} -
-
-

Products by Category

- {product_stats.products_by_category.length === 0 ? ( -

No data yet

- ) : ( - - - - - - - - {product_stats.products_by_category.map((_, i) => ( - - ))} - - - - )} -
- -
-

Renderer Usage

- {rendererPieData.length === 0 ? ( -

No data yet

- ) : ( - - - `${name}: ${value}`} - labelLine={false} - > - {rendererPieData.map((entry) => ( - - ))} - - - - - - )} -
-
- - {/* Row 5b — Render Backend Comparison */} - {render_backend_stats.length > 0 && ( -
-
-

Render Backend — Job Count

- - - - - - - - - - - -
- -
-

Render Backend — Avg Time

- - - - - - v != null ? [`${v.toFixed(1)}s`, ''] : ['—', '']} /> - - - - - -
-
- )} - - {/* Row 6 — Top 10 Products + Category Revenue */} -
-
-

Top 10 Products

- {top_products.length === 0 ? ( -

No data yet

- ) : ( - - - - - - - - - - - {top_products.map((p, i) => ( - - - - - - - ))} - -
PIM-IDProductCategoryOrders
{p.pim_id}{p.product_name || '—'}{p.category}{p.order_count}
- )} -
- -
-

Revenue by Category (€)

- {category_revenue.length === 0 ? ( -

No data yet

- ) : ( - - - - - - v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} /> - - {category_revenue.map((_, i) => ( - - ))} - - - - )} -
-
- - {/* Row 7 — Orders by User */} -
-

Orders by User

- {orders_by_user.length === 0 ? ( -

No data yet

- ) : ( - - - - - - - - - - - - {orders_by_user.map((u) => ( - - - - - - - - ))} - -
NameEmailRoleOrdersRevenue (€)
{u.full_name}{u.email} - - {u.role === 'project_manager' ? 'PM' : u.role} - - {u.order_count}€ {u.revenue.toFixed(2)}
- )} -
-
- ) -} - -function SummaryCard({ label, value }: { label: string; value: number | string }) { - return ( -
-

{value}

-

{label}

-
- ) -} - -function MetricRow({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ) -} diff --git a/frontend/src/components/dashboard/ClientDashboard.tsx b/frontend/src/components/dashboard/ClientDashboard.tsx deleted file mode 100644 index dad4695..0000000 --- a/frontend/src/components/dashboard/ClientDashboard.tsx +++ /dev/null @@ -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 ( -
-
-

Welcome, {user?.full_name}

-

Schaeffler Media Creation Pipeline

-
- -
- - - - -
- -
-
-

Quick Actions

-
- - - Upload Excel Order List - - - - View All Orders - -
-
- -
-

Recent Orders

- {orders && orders.length > 0 ? ( -
- {orders.slice(0, 5).map((order) => ( - - {order.order_number} -
- {order.estimated_price != null && ( - - € {Number(order.estimated_price).toFixed(2)} - - )} - -
- - ))} -
- ) : ( -

No orders yet. Upload an Excel file to get started.

- )} -
-
-
- ) -} - -function StatCard({ label, value, icon: Icon, color }: { label: string; value: number; icon: any; color: string }) { - const colors: Record = { - 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 ( -
-
- -
-

{value}

-

{label}

-
- ) -} - -function StatusBadge({ status }: { status: string }) { - const map: Record = { - draft: 'badge-gray', - submitted: 'badge-blue', - processing: 'badge-yellow', - completed: 'badge-green', - rejected: 'badge-red', - } - return {status} -} diff --git a/frontend/src/components/dashboard/DashboardCustomizeModal.tsx b/frontend/src/components/dashboard/DashboardCustomizeModal.tsx index 2520b4b..2ab2f54 100644 --- a/frontend/src/components/dashboard/DashboardCustomizeModal.tsx +++ b/frontend/src/components/dashboard/DashboardCustomizeModal.tsx @@ -6,14 +6,24 @@ import { updateDashboardConfig, updateTenantDefaultDashboard } from '../../api/d import type { WidgetConfig, WidgetType } from '../../api/dashboard' const WIDGET_LABELS: Record = { - 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>( 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 ( -
-
- {/* Header */} -
-

- {tenantMode ? 'Edit Tenant Default Dashboard' : 'Customize Dashboard'} -

- -
- - {/* Widget list */} -
-

- Select which widgets are visible on the dashboard. -

- {ALL_WIDGET_TYPES.map((type) => ( + function renderGroup(title: string, types: WidgetType[]) { + return ( +
+

{title}

+
+ {types.map((type) => (
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+

+ {tenantMode ? 'Edit Tenant Default Dashboard' : 'Customize Dashboard'} +

+ +
+ + {/* Widget list */} +
+

+ Select which widgets are visible on the dashboard. +

+ {renderGroup('Operational', OPERATIONAL_TYPES)} + {renderGroup('Analytics', ANALYTICS_TYPES)} +
{/* Footer */} -
+
diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx index 40a0f8e..9acfd1d 100644 --- a/frontend/src/components/dashboard/DashboardGrid.tsx +++ b/frontend/src/components/dashboard/DashboardGrid.tsx @@ -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 = { - ProductionStats: { title: 'Production Stats', icon: }, - QueueStatus: { title: 'Queue Status', icon: }, - RecentRenders: { title: 'Recent Renders', icon: }, - CostOverview: { title: 'Cost Overview', icon: }, - WorkerStatus: { title: 'Worker Status', icon: }, + ProductionStats: { title: 'Production Stats', icon: }, + QueueStatus: { title: 'Queue Status', icon: }, + RecentRenders: { title: 'Recent Renders', icon: }, + CostOverview: { title: 'Cost Overview', icon: }, + WorkerStatus: { title: 'Worker Status', icon: }, + KPISummary: { title: 'KPI Summary', icon: }, + OrderThroughput: { title: 'Order Throughput', icon: }, + RevenueChart: { title: 'Revenue', icon: }, + ItemStatus: { title: 'Item & Render Status', icon: }, + ProcessingTimes: { title: 'Processing Times', icon: }, + RenderTimeByOutputType: { title: 'Render Time by Output Type', icon: }, + OutputTypeUsage: { title: 'Output Type Usage', icon: }, + TopProducts: { title: 'Top 10 Products', icon: }, + OrdersByUser: { title: 'Orders by User', icon: }, + RenderBackendStats: { title: 'Render Backend Stats', icon: }, } +// Analytics widget types that need a timeframe +const ANALYTICS_WIDGET_TYPES = new Set([ + '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 - case 'QueueStatus': return - case 'RecentRenders': return - case 'CostOverview': return - case 'WorkerStatus': return - default: return

Unknown widget

+ case 'ProductionStats': return + case 'QueueStatus': return + case 'RecentRenders': return + case 'CostOverview': return + case 'WorkerStatus': return + case 'KPISummary': return + case 'OrderThroughput': return + case 'RevenueChart': return + case 'ItemStatus': return + case 'ProcessingTimes': return + case 'RenderTimeByOutputType': return + case 'OutputTypeUsage': return + case 'TopProducts': return + case 'OrdersByUser': return + case 'RenderBackendStats': return + default: return

Unknown widget

} } -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 ( +
+ {PRESETS.map(({ key, label }) => ( + + ))} + {preset === 'custom' && ( +
+ setCustomFrom(e.target.value)} + className="border border-border-default rounded px-2 py-0.5 text-xs" + /> + + setCustomTo(e.target.value)} + className="border border-border-default rounded px-2 py-0.5 text-xs" + /> +
+ )} +
+ ) +} + +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 (
{/* Toolbar */} -
+
+
) } + +export default function DashboardGrid() { + return ( + + + + ) +} diff --git a/frontend/src/components/dashboard/DashboardTimeframeContext.tsx b/frontend/src/components/dashboard/DashboardTimeframeContext.tsx new file mode 100644 index 0000000..d1bacdb --- /dev/null +++ b/frontend/src/components/dashboard/DashboardTimeframeContext.tsx @@ -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(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('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 ( + + {children} + + ) +} diff --git a/frontend/src/components/dashboard/widgets/ItemStatusWidget.tsx b/frontend/src/components/dashboard/widgets/ItemStatusWidget.tsx new file mode 100644 index 0000000..3a0a369 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/ItemStatusWidget.tsx @@ -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
+ if (error) return

Failed to load item status

+ 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 ( +
+
+

Item Status

+ {noItemData ? ( +

No data yet

+ ) : ( + + + `${name}: ${value}`} labelLine={false}> + {itemPie.map((e) => )} + + + + + + )} +
+ +
+

Render Status

+ {noRenderData ? ( +

No data yet

+ ) : ( + + + `${name}: ${value}`} labelLine={false}> + {renderPie.map((e) => )} + + + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/KPISummaryWidget.tsx b/frontend/src/components/dashboard/widgets/KPISummaryWidget.tsx new file mode 100644 index 0000000..da3e88c --- /dev/null +++ b/frontend/src/components/dashboard/widgets/KPISummaryWidget.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query' +import { getDashboardKPIs } from '../../../api/analytics' +import { useDashboardTimeframe } from '../DashboardTimeframeContext' + +function Skeleton() { + return ( +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ ) +} + +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 + if (error) return

Failed to load KPIs

+ 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 ( +
+ {cards.map(({ label, value }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/OrderThroughputWidget.tsx b/frontend/src/components/dashboard/widgets/OrderThroughputWidget.tsx new file mode 100644 index 0000000..fb5482c --- /dev/null +++ b/frontend/src/components/dashboard/widgets/OrderThroughputWidget.tsx @@ -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
+ if (error) return

Failed to load throughput

+ if (!data || data.throughput.length === 0) { + return

No data yet

+ } + + return ( + + + + + + + + + + + + ) +} diff --git a/frontend/src/components/dashboard/widgets/OrdersByUserWidget.tsx b/frontend/src/components/dashboard/widgets/OrdersByUserWidget.tsx new file mode 100644 index 0000000..4b48034 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/OrdersByUserWidget.tsx @@ -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
+ if (error) return

Failed to load user data

+ if (!data || data.orders_by_user.length === 0) { + return

No data yet

+ } + + return ( +
+ + + + + + + + + + + + {data.orders_by_user.map((u) => ( + + + + + + + + ))} + +
NameEmailRoleOrdersRevenue
{u.full_name}{u.email} + + {u.role === 'project_manager' ? 'PM' : u.role} + + {u.order_count}€ {u.revenue.toFixed(2)}
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/OutputTypeUsageWidget.tsx b/frontend/src/components/dashboard/widgets/OutputTypeUsageWidget.tsx new file mode 100644 index 0000000..467b1a2 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/OutputTypeUsageWidget.tsx @@ -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
+ if (error) return

Failed to load usage data

+ 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 ( +
+
+

Output Type Usage

+ {data.output_type_usage.length === 0 ? ( +

No data yet

+ ) : ( + + + + + + + + + + )} +
+ +
+

Renderer Usage

+ {rendererPie.length === 0 ? ( +

No data yet

+ ) : ( + + + `${name}: ${value}`} labelLine={false}> + {rendererPie.map((e) => )} + + + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/ProcessingTimesWidget.tsx b/frontend/src/components/dashboard/widgets/ProcessingTimesWidget.tsx new file mode 100644 index 0000000..6e3f71a --- /dev/null +++ b/frontend/src/components/dashboard/widgets/ProcessingTimesWidget.tsx @@ -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 ( + + {label} + {value} + + ) +} + +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
+ if (error) return

Failed to load timing data

+ if (!data) return null + + const { processing_times: pt, render_times: rt } = data + + return ( +
+
+

Processing Times

+ + + + + + + +
+
+ +
+

Render Time Summary

+ + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/ProductionStatsWidget.tsx b/frontend/src/components/dashboard/widgets/ProductionStatsWidget.tsx index 27f6678..1f2796e 100644 --- a/frontend/src/components/dashboard/widgets/ProductionStatsWidget.tsx +++ b/frontend/src/components/dashboard/widgets/ProductionStatsWidget.tsx @@ -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({ - 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: }, - { label: 'Completed Orders', value: data?.completed_orders ?? 0, icon: }, - { label: 'Rendering Items', value: data?.total_rendering_items ?? 0, icon: }, + { label: 'Open Orders', value: (s?.total_orders ?? 0) - (s?.completed_orders ?? 0), icon: }, + { label: 'Completed Orders', value: s?.completed_orders ?? 0, icon: }, + { label: 'Rendering Items', value: s?.total_rendering_items ?? 0, icon: }, ] return ( diff --git a/frontend/src/components/dashboard/widgets/RenderBackendStatsWidget.tsx b/frontend/src/components/dashboard/widgets/RenderBackendStatsWidget.tsx new file mode 100644 index 0000000..176d8e9 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/RenderBackendStatsWidget.tsx @@ -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
+ if (error) return

Failed to load backend stats

+ if (!data || data.render_backend_stats.length === 0) { + return

No data yet

+ } + + return ( +
+
+

Job Count by Backend

+ + + + + + + + + + + +
+ +
+

Avg Render Time (s)

+ + + + + + v != null ? [`${v.toFixed(1)} s`, ''] : ['—', '']} + /> + + + + + +
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/RenderTimeByOutputTypeWidget.tsx b/frontend/src/components/dashboard/widgets/RenderTimeByOutputTypeWidget.tsx new file mode 100644 index 0000000..91894bc --- /dev/null +++ b/frontend/src/components/dashboard/widgets/RenderTimeByOutputTypeWidget.tsx @@ -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
+ if (error) return

Failed to load render times

+ if (!data || !data.render_time_by_output_type || data.render_time_by_output_type.length === 0) { + return

No data yet

+ } + + const rows = data.render_time_by_output_type + const chartHeight = Math.max(160, rows.length * 44) + + return ( +
+ + + + v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`} + /> + + { + const n = typeof v === 'number' ? v : null + return [n != null ? (n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(0)} s`) : '—', name ?? ''] + }} + /> + + + + + + +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
Output-TypJobsØP50MinMax
{r.output_type}{r.job_count}{fmtSeconds(r.avg_render_s)}{fmtSeconds(r.p50_render_s)}{fmtSeconds(r.min_render_s)}{fmtSeconds(r.max_render_s)}
+
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/RevenueChartWidget.tsx b/frontend/src/components/dashboard/widgets/RevenueChartWidget.tsx new file mode 100644 index 0000000..bed026c --- /dev/null +++ b/frontend/src/components/dashboard/widgets/RevenueChartWidget.tsx @@ -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
+ if (error) return

Failed to load revenue

+ if (!data) return null + + return ( +
+
+

Revenue per Month (€)

+ {data.revenue.length === 0 ? ( +

No data yet

+ ) : ( + + + + + + + v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue'] + } + /> + + + + )} +
+ +
+

Revenue by Category (€)

+ {data.category_revenue.length === 0 ? ( +

No data yet

+ ) : ( + + + + + + + v != null ? [`€ ${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue'] + } + /> + + {data.category_revenue.map((_, i) => ( + + ))} + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard/widgets/TopProductsWidget.tsx b/frontend/src/components/dashboard/widgets/TopProductsWidget.tsx new file mode 100644 index 0000000..c155202 --- /dev/null +++ b/frontend/src/components/dashboard/widgets/TopProductsWidget.tsx @@ -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
+ if (error) return

Failed to load top products

+ if (!data || data.top_products.length === 0) { + return

No data yet

+ } + + return ( +
+ + + + + + + + + + + {data.top_products.map((p, i) => ( + + + + + + + ))} + +
PIM-IDProductCategoryOrders
{p.pim_id}{p.product_name || '—'}{p.category}{p.order_count}
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e782ff0..a580a51 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ( -
- {/* Configurable widget grid — visible to all roles */} -
-

Dashboard

- -
- - {/* Role-based analytics section */} - {isPrivileged ? : } +
+

Dashboard

+
) }