From ba167943ddf972e14dfe5d4f46ccd07eaf383d74 Mon Sep 17 00:00:00 2001 From: hex-bot Date: Sat, 9 May 2026 08:11:35 -0400 Subject: [PATCH] CUB-131: Build Filament Detail page with usage history --- frontend/src/App.tsx | 2 + frontend/src/pages/FilamentDetailPage.tsx | 391 ++++++++++++++++++++++ frontend/src/services/filamentService.ts | 12 +- frontend/src/types/filament.ts | 13 + 4 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/FilamentDetailPage.tsx 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 && ( + <> +
+ + + + + + + + + + + + {usageLogs.map((log: UsageLog) => ( + + + + + + + + ))} + +
DatePrint Jobmm ExtrudedGrams UsedCost
+ {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