diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a5a8c3c..d755456 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
+import Dashboard from './pages/Dashboard'
import InventoryPage from './pages/InventoryPage'
const queryClient = new QueryClient()
@@ -15,7 +16,8 @@ export default function App() {
- } />
+ } />
+ } />
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 (
+
+
+
+
+
+ | Name |
+ Status |
+ Duration |
+ Filament |
+ Cost |
+
+
+
+ {jobs.map((job) => (
+
+ | {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 (
+
+ )
+}
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 (
+
+
+
+
+ {/* Summary Cards Grid */}
+
+
+ 0 ? 'amber' : 'emerald'}
+ />
+
+
+
+
+ {/* Quick Actions */}
+
+
+
+
+
+ {/* Recent Prints Section */}
+
+
+
+ )
+}
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/*"]