diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a5a8c3c..40584f0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import InventoryPage from './pages/InventoryPage'
+import FilamentDetailPage from './pages/FilamentDetailPage'
const queryClient = new QueryClient()
@@ -16,6 +17,7 @@ export default function App() {
} />
+ } />
diff --git a/frontend/src/pages/FilamentDetailPage.tsx b/frontend/src/pages/FilamentDetailPage.tsx
new file mode 100644
index 0000000..2a0dcde
--- /dev/null
+++ b/frontend/src/pages/FilamentDetailPage.tsx
@@ -0,0 +1,391 @@
+import { useState } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { ArrowLeft, Pencil, Trash2, AlertTriangle, Loader2, Archive } from 'lucide-react'
+import ColorSwatch from '../components/ColorSwatch'
+import { fetchFilamentById, fetchUsageLogs, deleteFilament } from '../services/filamentService'
+import type { UsageLog } from '../types/filament'
+
+export default function FilamentDetailPage() {
+ const { id } = useParams<{ id: string }>()
+ const spoolId = Number(id)
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const [deleteConfirm, setDeleteConfirm] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+
+ const {
+ data: spool,
+ isLoading: spoolLoading,
+ error: spoolError,
+ refetch: refetchSpool,
+ } = useQuery({
+ queryKey: ['filament', spoolId],
+ queryFn: () => fetchFilamentById(spoolId),
+ enabled: !isNaN(spoolId),
+ })
+
+ const {
+ data: usageLogs,
+ isLoading: usageLoading,
+ error: usageError,
+ refetch: refetchUsage,
+ } = useQuery({
+ queryKey: ['usage-logs', spoolId],
+ queryFn: () => fetchUsageLogs(spoolId),
+ enabled: !isNaN(spoolId),
+ })
+
+ const handleDelete = async () => {
+ setDeleting(true)
+ try {
+ await deleteFilament(spoolId)
+ queryClient.invalidateQueries({ queryKey: ['filaments'] })
+ navigate('/')
+ } catch {
+ setDeleting(false)
+ setDeleteConfirm(false)
+ }
+ }
+
+ // ── Loading state ──
+ if (spoolLoading) {
+ return (
+
+
+ Loading spool details…
+
+ )
+ }
+
+ // ── Error state ──
+ if (spoolError || !spool) {
+ return (
+
+
+
Failed to load spool details.
+
+
+
+
+
+ )
+ }
+
+ const pctRemaining = spool.initial_grams > 0
+ ? (spool.remaining_grams / spool.initial_grams) * 100
+ : 0
+ const isLow = spool.remaining_grams <= (spool.low_stock_threshold_grams || 0)
+
+ return (
+
+ {/* ── Header Bar ── */}
+
+
+
+
+
+
+
+
+
+ {/* ── Spool Info Card ── */}
+
+ {/* Card header */}
+
+
+
+
+
{spool.name}
+
+ {spool.brand && {spool.brand} · }
+ {spool.diameter_mm}mm
+
+
+
+
+ {isLow ? (
+
+ Low Stock
+
+ ) : (
+
+ In Stock
+
+ )}
+
+
+
+ {/* Card body — 2-column grid */}
+
+
+
+
+
+
+
+
+ {spool.remaining_grams.toLocaleString()} g
+
+ }
+ />
+
+
+
+
+ {/* Progress Bar */}
+
+
+
+
+ {spool.remaining_grams.toLocaleString()}g
+ {' '}
+ of {spool.initial_grams.toLocaleString()}g
+
+
+ {pctRemaining.toFixed(1)}%
+
+
+
+
+
+
+ {/* ── Usage History ── */}
+
+
+
Usage History
+
+
+ {/* Loading sub-state */}
+ {usageLoading && (
+
+
+ Loading usage history…
+
+ )}
+
+ {/* Usage error sub-state */}
+ {usageError && !usageLoading && (
+
+
+ Failed to load usage history.
+
+
+
+ )}
+
+ {/* Empty state */}
+ {!usageLoading && !usageError && usageLogs && usageLogs.length === 0 && (
+
+
+
No usage recorded yet.
+
+ Usage data will appear here when this spool is used in print jobs.
+
+
+ )}
+
+ {/* Table — desktop */}
+ {!usageLoading && !usageError && usageLogs && usageLogs.length > 0 && (
+ <>
+
+
+
+
+ | Date |
+ Print Job |
+ mm Extruded |
+ Grams Used |
+ Cost |
+
+
+
+ {usageLogs.map((log: UsageLog) => (
+
+ |
+ {new Date(log.logged_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+ |
+
+ {log.print_job_name || `Job #${log.print_job_id ?? log.id}`}
+ |
+
+ {log.mm_extruded.toLocaleString()}
+ |
+
+ {log.grams_used.toFixed(2)} g
+ |
+
+ {log.cost_usd != null ? `$${log.cost_usd.toFixed(3)}` : '—'}
+ |
+
+ ))}
+
+
+
+
+ {/* Mobile cards */}
+
+ {usageLogs.map((log: UsageLog) => (
+
+
+
+ {log.print_job_name || `Job #${log.print_job_id ?? log.id}`}
+
+
+ {new Date(log.logged_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+
+ Extruded:{' '}
+
+ {log.mm_extruded.toLocaleString()} mm
+
+
+
+ Used:{' '}
+
+ {log.grams_used.toFixed(2)} g
+
+
+
+ {log.cost_usd != null ? (
+ <>
+ Cost:{' '}
+
+ ${log.cost_usd.toFixed(3)}
+
+ >
+ ) : (
+ '—'
+ )}
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ {/* ── Delete Confirmation Modal ── */}
+ {deleteConfirm && (
+
+
+
+
+
+
+ Delete “{spool.name}”?
+
+
+ This permanently removes the spool and all usage history. This cannot be undone.
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+/** Small detail row for the info card grid */
+function DetailItem({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ )
+}
diff --git a/frontend/src/services/filamentService.ts b/frontend/src/services/filamentService.ts
index 2297ef2..c691af5 100644
--- a/frontend/src/services/filamentService.ts
+++ b/frontend/src/services/filamentService.ts
@@ -1,5 +1,5 @@
import axios from 'axios'
-import type { FilamentSpool, ListResponse, FilamentFilter } from '../types/filament'
+import type { FilamentSpool, ListResponse, UsageLog, FilamentFilter } from '../types/filament'
const API_BASE = '/api'
@@ -19,6 +19,16 @@ export async function fetchFilaments(filter: FilamentFilter): Promise {
+ const res = await axios.get(`${API_BASE}/filaments/${id}`)
+ return res.data
+}
+
+export async function fetchUsageLogs(spoolId: number): Promise {
+ const res = await axios.get(`${API_BASE}/usage-logs?spool_id=${spoolId}`)
+ return res.data
+}
+
export async function deleteFilament(id: number): Promise {
await axios.delete(`${API_BASE}/filaments/${id}`)
}
diff --git a/frontend/src/types/filament.ts b/frontend/src/types/filament.ts
index 5635fee..3634957 100644
--- a/frontend/src/types/filament.ts
+++ b/frontend/src/types/filament.ts
@@ -59,6 +59,19 @@ export interface ListResponse {
offset: number
}
+export interface UsageLog {
+ id: number
+ spool_id: number
+ print_job_id?: number
+ print_job_name?: string
+ mm_extruded: number
+ grams_used: number
+ cost_usd?: number
+ logged_at: string
+ notes?: string
+ created_at: string
+}
+
export interface FilamentFilter {
material?: string
finish?: string