diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef35dd3..a5a8c3c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,25 @@ -import { useState, useEffect } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import InventoryPage from './pages/InventoryPage' -function App() { - const [health, setHealth] = useState(null) - - useEffect(() => { - fetch('/api/health') - .then(r => r.json()) - .then(setHealth) - .catch(console.error) - }, []) +const queryClient = new QueryClient() +export default function App() { return ( -
-
-

Extrudex

-

React frontend scaffold

- {health && ( -
-            {JSON.stringify(health, null, 2)}
-          
- )} -
-
+ + +
+
+
E
+

Extrudex

+
+
+ + } /> + +
+
+
+
) } - -export default App diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/components/ColorSwatch.tsx b/frontend/src/components/ColorSwatch.tsx new file mode 100644 index 0000000..92bc7ec --- /dev/null +++ b/frontend/src/components/ColorSwatch.tsx @@ -0,0 +1,18 @@ +interface ColorSwatchProps { + colorHex: string + size?: number +} + +export default function ColorSwatch({ colorHex, size = 24 }: ColorSwatchProps) { + return ( +
+ ) +} diff --git a/frontend/src/pages/.gitkeep b/frontend/src/pages/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx new file mode 100644 index 0000000..2728f65 --- /dev/null +++ b/frontend/src/pages/InventoryPage.tsx @@ -0,0 +1,339 @@ +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Search, Filter, ChevronLeft, ChevronRight, Trash2, Pencil, Plus, AlertTriangle } from 'lucide-react' +import ColorSwatch from '../components/ColorSwatch' +import { fetchFilaments, deleteFilament } from '../services/filamentService' +import type { FilamentSpool, FilamentFilter } from '../types/filament' + +const PAGE_SIZE = 20 + +type SortField = 'name' | 'remaining_grams' | 'cost_usd' +type SortDir = 'asc' | 'desc' + +export default function InventoryPage() { + const [search, setSearch] = useState('') + const [material, setMaterial] = useState('') + const [finish, setFinish] = useState('') + const [lowStockOnly, setLowStockOnly] = useState(false) + const [sortBy, setSortBy] = useState('name') + const [sortDir, setSortDir] = useState('asc') + const [page, setPage] = useState(0) + const [deleteId, setDeleteId] = useState(null) + + const filter: FilamentFilter = useMemo(() => ({ + material: material || undefined, + finish: finish || undefined, + low_stock: lowStockOnly, + sort_by: sortBy, + sort_dir: sortDir, + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), [material, finish, lowStockOnly, sortBy, sortDir, page]) + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['filaments', filter], + queryFn: () => fetchFilaments(filter), + }) + + const filaments = data?.data ?? [] + const total = data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + // Client-side search filter (name/barcode) since backend may not support it yet. + const filtered = useMemo(() => { + if (!search.trim()) return filaments + const q = search.toLowerCase() + return filaments.filter( + (f: FilamentSpool) => + f.name.toLowerCase().includes(q) || + (f.barcode && f.barcode.toLowerCase().includes(q)) + ) + }, [filaments, search]) + + const handleSort = (field: SortField) => { + if (sortBy === field) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy(field) + setSortDir('asc') + } + setPage(0) + } + + const handleDelete = async (id: number) => { + await deleteFilament(id) + setDeleteId(null) + refetch() + } + + const SortIndicator = ({ field }: { field: SortField }) => { + if (sortBy !== field) return + return {sortDir === 'asc' ? '↑' : '↓'} + } + + return ( +
+ {/* Header */} +
+
+

Filament Inventory

+

{total} spool(s) total

+
+ +
+ + {/* Filters */} +
+ {/* Search */} +
+ + { setSearch(e.target.value); setPage(0) }} + className="w-full rounded-lg bg-slate-800 border border-slate-700 pl-9 pr-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" + /> +
+ + {/* Material filter */} + + + {/* Finish filter */} + + + {/* Low stock toggle */} + +
+ + {/* Loading / Error */} + {isLoading && ( +
Loading spools…
+ )} + {error && ( +
+ Failed to load inventory. + +
+ )} + + {/* Desktop Table */} + {!isLoading && !error && ( + <> +
+ + + + + + + + + + + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((spool: FilamentSpool) => { + const isLow = spool.remaining_grams <= spool.low_stock_threshold_grams + return ( + + + + + + + + + + + ) + })} + +
handleSort('name')}> + Name + MaterialFinishColor handleSort('remaining_grams')}> + Remaining + handleSort('cost_usd')}> + Cost + StatusActions
No spools found.
{spool.name}{spool.material_base?.name ?? '—'}{spool.material_finish?.name ?? '—'} +
+ + {spool.color_hex} +
+
{spool.remaining_grams.toLocaleString()} g{spool.cost_usd != null ? `$${spool.cost_usd.toFixed(2)}` : '—'} + {isLow ? ( + + Low + + ) : ( + OK + )} + +
+ + +
+
+
+ + {/* Mobile Cards */} +
+ {filtered.length === 0 && ( +
No spools found.
+ )} + {filtered.map((spool: FilamentSpool) => { + const isLow = spool.remaining_grams <= spool.low_stock_threshold_grams + return ( +
+
+
+
{spool.name}
+
{spool.material_base?.name ?? '—'} · {spool.material_finish?.name ?? '—'}
+
+ {isLow ? ( + + Low + + ) : ( + OK + )} +
+ +
+
+ + {spool.color_hex} +
+
+ +
+ Remaining: {spool.remaining_grams.toLocaleString()} g + {spool.cost_usd != null ? `$${spool.cost_usd.toFixed(2)}` : '—'} +
+ +
+ + +
+
+ ) + })} +
+ + {/* Pagination */} +
+ + Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total} + +
+ + {page + 1} / {totalPages} + +
+
+ + )} + + {/* Delete confirmation modal */} + {deleteId !== null && ( +
+
+
+
+ +
+
+

Delete Spool?

+

This action cannot be undone.

+
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/services/.gitkeep b/frontend/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/services/filamentService.ts b/frontend/src/services/filamentService.ts new file mode 100644 index 0000000..2297ef2 --- /dev/null +++ b/frontend/src/services/filamentService.ts @@ -0,0 +1,24 @@ +import axios from 'axios' +import type { FilamentSpool, ListResponse, FilamentFilter } from '../types/filament' + +const API_BASE = '/api' + +export async function fetchFilaments(filter: FilamentFilter): Promise> { + const params = new URLSearchParams() + if (filter.material) params.set('material', filter.material) + if (filter.finish) params.set('finish', filter.finish) + if (filter.color) params.set('color', filter.color) + if (filter.low_stock) params.set('low_stock', 'true') + if (filter.search) params.set('search', filter.search) + if (filter.sort_by) params.set('sort_by', filter.sort_by) + if (filter.sort_dir) params.set('sort_dir', filter.sort_dir) + if (filter.limit !== undefined) params.set('limit', String(filter.limit)) + if (filter.offset !== undefined) params.set('offset', String(filter.offset)) + + const res = await axios.get>(`${API_BASE}/filaments?${params.toString()}`) + return res.data +} + +export async function deleteFilament(id: number): Promise { + await axios.delete(`${API_BASE}/filaments/${id}`) +} diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/types/filament.ts b/frontend/src/types/filament.ts new file mode 100644 index 0000000..5635fee --- /dev/null +++ b/frontend/src/types/filament.ts @@ -0,0 +1,72 @@ +// Extrudex domain types + +export interface MaterialBase { + id: number + name: string + density_g_cm3: number + extrusion_temp_min?: number + extrusion_temp_max?: number + bed_temp_min?: number + bed_temp_max?: number + created_at: string + updated_at: string +} + +export interface MaterialFinish { + id: number + name: string + description?: string + created_at: string + updated_at: string +} + +export interface MaterialModifier { + id: number + name: string + description?: string + created_at: string + updated_at: string +} + +export interface FilamentSpool { + id: number + name: string + material_base_id: number + material_base?: MaterialBase + material_finish_id: number + material_finish?: MaterialFinish + material_modifier_id?: number + material_modifier?: MaterialModifier + 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 + deleted_at?: string + created_at: string + updated_at: string +} + +export interface ListResponse { + data: T[] + total: number + limit: number + offset: number +} + +export interface FilamentFilter { + material?: string + finish?: string + color?: string + low_stock?: boolean + search?: string + sort_by?: string + sort_dir?: 'asc' | 'desc' + limit?: number + offset?: number +}