From 32798fbf14bdae984ff1769042926729a7ce4fc9 Mon Sep 17 00:00:00 2001 From: hex-bot Date: Thu, 7 May 2026 20:07:45 -0400 Subject: [PATCH] CUB-133: Build Dashboard page with summary cards --- frontend/src/App.tsx | 26 +--- frontend/src/components/LoadingSpinner.tsx | 8 ++ frontend/src/components/RecentPrints.tsx | 76 +++++++++++ frontend/src/components/SummaryCard.tsx | 31 +++++ frontend/src/main.tsx | 17 ++- frontend/src/pages/Dashboard.tsx | 139 +++++++++++++++++++++ frontend/src/services/api.ts | 19 +++ frontend/src/types/index.ts | 42 +++++++ frontend/tsconfig.json | 1 + 9 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/LoadingSpinner.tsx create mode 100644 frontend/src/components/RecentPrints.tsx create mode 100644 frontend/src/components/SummaryCard.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/types/index.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef35dd3..0281e81 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,12 @@ -import { useState, useEffect } from 'react' +import { Routes, Route } from 'react-router-dom' +import Dashboard from './pages/Dashboard' function App() { - const [health, setHealth] = useState(null) - - useEffect(() => { - fetch('/api/health') - .then(r => r.json()) - .then(setHealth) - .catch(console.error) - }, []) - return ( -
-
-

Extrudex

-

React frontend scaffold

- {health && ( -
-            {JSON.stringify(health, null, 2)}
-          
- )} -
+
+ + } /> +
) } diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..9a17fa1 --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,8 @@ +export default function LoadingSpinner() { + return ( +
+
+

Loading dashboard…

+
+ ) +} diff --git a/frontend/src/components/RecentPrints.tsx b/frontend/src/components/RecentPrints.tsx new file mode 100644 index 0000000..8f09e53 --- /dev/null +++ b/frontend/src/components/RecentPrints.tsx @@ -0,0 +1,76 @@ +import type { PrintJob } from '../types' +import { Clock, FileText } from 'lucide-react' + +interface RecentPrintsProps { + jobs: PrintJob[] +} + +export default function RecentPrints({ jobs }: RecentPrintsProps) { + if (jobs.length === 0) { + return ( +
+ +

No recent print jobs

+
+ ) + } + + const statusColor = (status: string) => { + switch (status.toLowerCase()) { + case 'completed': return 'text-emerald-400 bg-emerald-500/10' + case 'in_progress': return 'text-sky-400 bg-sky-500/10' + case 'failed': return 'text-red-400 bg-red-500/10' + default: return 'text-slate-400 bg-slate-500/10' + } + } + + return ( +
+
+ + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + ))} + +
NameStatusDurationFilamentCost
{job.name} + + {job.status} + + +
+ + {formatDuration(job.duration_seconds)} +
+
+ {job.filament_used_grams?.toFixed(1) ?? '-'} g + + ${job.cost_usd?.toFixed(2) ?? '-'} +
+
+
+ ) +} + +function formatDuration(seconds: number): string { + if (!seconds) return '-' + const hrs = Math.floor(seconds / 3600) + const mins = Math.floor((seconds % 3600) / 60) + if (hrs > 0) return `${hrs}h ${mins}m` + return `${mins}m` +} diff --git a/frontend/src/components/SummaryCard.tsx b/frontend/src/components/SummaryCard.tsx new file mode 100644 index 0000000..525537b --- /dev/null +++ b/frontend/src/components/SummaryCard.tsx @@ -0,0 +1,31 @@ +import type { LucideIcon } from 'lucide-react' + +interface SummaryCardProps { + title: string + value: string | number + icon: LucideIcon + color: 'emerald' | 'amber' | 'sky' | 'violet' +} + +const colorMap = { + emerald: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400', icon: 'text-emerald-400' }, + amber: { bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400', icon: 'text-amber-400' }, + sky: { bg: 'bg-sky-500/10', border: 'border-sky-500/20', text: 'text-sky-400', icon: 'text-sky-400' }, + violet: { bg: 'bg-violet-500/10', border: 'border-violet-500/20', text: 'text-violet-400', icon: 'text-violet-400' }, +} + +export default function SummaryCard({ title, value, icon: Icon, color }: SummaryCardProps) { + const c = colorMap[color] + + return ( +
+
+

{title}

+

{value}

+
+
+ +
+
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index db032b7..5e85ffd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,25 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, +}) + createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..da38dbb --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,139 @@ +import { useQuery } from '@tanstack/react-query' +import { getFilaments, getPrintJobs } from '../services/api' +import SummaryCard from '../components/SummaryCard' +import RecentPrints from '../components/RecentPrints' +import LoadingSpinner from '../components/LoadingSpinner' +import { Package, AlertTriangle, Printer, DollarSign, Plus, List } from 'lucide-react' + +export default function Dashboard() { + const { + data: filamentData, + isLoading: filamentLoading, + error: filamentError, + } = useQuery({ + queryKey: ['filaments', 'count'], + queryFn: () => getFilaments({ limit: 1 }), + }) + + const { + data: lowStockData, + isLoading: lowStockLoading, + error: lowStockError, + } = useQuery({ + queryKey: ['filaments', 'lowStock'], + queryFn: () => getFilaments({ low_stock: true, limit: 1 }), + }) + + const { + data: recentPrints, + isLoading: printsLoading, + error: printsError, + } = useQuery({ + queryKey: ['printJobs', 'recent'], + queryFn: () => getPrintJobs({ limit: 5 }), + }) + + const { + data: allPrints, + isLoading: costLoading, + error: costError, + } = useQuery({ + queryKey: ['printJobs', 'all'], + queryFn: () => getPrintJobs({ limit: 1000 }), + }) + + const totalSpools = filamentData?.total ?? 0 + const lowStockCount = lowStockData?.total ?? 0 + const recentPrintJobs = recentPrints?.data ?? [] + + // Calculate cost this month from all print jobs + const now = new Date() + const currentMonth = now.getMonth() + const currentYear = now.getFullYear() + const monthlyCost = + allPrints?.data + .filter((job) => { + const d = new Date(job.started_at) + return d.getMonth() === currentMonth && d.getFullYear() === currentYear + }) + .reduce((sum, job) => sum + (job.cost_usd ?? 0), 0) ?? 0 + + const isLoading = filamentLoading || lowStockLoading || printsLoading || costLoading + const error = filamentError || lowStockError || printsError || costError + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+

Error

+

{(error as Error).message || 'Failed to load dashboard data.'}

+
+
+ ) + } + + return ( +
+
+
+

Dashboard

+

Overview of your Extrudex inventory and prints

+
+ + {/* Summary Cards Grid */} +
+ + 0 ? 'amber' : 'emerald'} + /> + + +
+ + {/* Quick Actions */} +
+ + +
+ + {/* Recent Prints Section */} +
+

Recent Prints

+ +
+
+
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..1d5a4d1 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,19 @@ +import axios from 'axios' +import type { ListResponse, FilamentSpool, PrintJob } from '../types' + +const api = axios.create({ + baseURL: '/api', + headers: { 'Content-Type': 'application/json' }, +}) + +export async function getFilaments(params?: { low_stock?: boolean; limit?: number; offset?: number }) { + const res = await api.get>('/filaments', { params }) + return res.data +} + +export async function getPrintJobs(params?: { limit?: number; offset?: number }) { + const res = await api.get>('/print-jobs', { params }) + return res.data +} + +export default api diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..edf9a69 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,42 @@ +export interface FilamentSpool { + id: number; + name: string; + material_base_id: number; + material_finish_id: number; + material_modifier_id: number; + color_hex: string; + brand: string; + diameter_mm: number; + initial_grams: number; + remaining_grams: number; + spool_weight_grams: number; + cost_usd: number; + low_stock_threshold_grams: number; + notes: string; + barcode: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface PrintJob { + id: number; + name: string; + printer_id: number; + filament_id: number; + status: string; + duration_seconds: number; + filament_used_grams: number; + cost_usd: number; + started_at: string; + completed_at: string | null; + created_at: string; + updated_at: string; +} + +export interface ListResponse { + data: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 488cd9d..7fcb29c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,6 +15,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "ignoreDeprecations": "5.0", "baseUrl": ".", "paths": { "@/*": ["src/*"]