From ef238cfdbe61f9ead088df03c8e899eadfae8645 Mon Sep 17 00:00:00 2001 From: hex-bot Date: Tue, 12 May 2026 18:07:06 -0400 Subject: [PATCH] CUB-129: Build Print Jobs list page - Add PrintJob types (PrintJob, PrintJobFilter) with status union type - Add printJobService.ts using axios + TanStack Query - Build PrintJobsPage.tsx with: - Desktop table: job name, printer, color-coded status badge, started time, duration, filament used, cost - Mobile card layout with grid-based info display - Filter by status (pending/printing/completed/failed) - Filter by printer name - Sort by any column (name, started_at, duration, filament, cost) - Pagination with 20 per page - Loading spinner, error with retry, empty state - Client-side text search by job/printer name - Duration formatting (h/m/s) - Add navigation bar in App.tsx with Inventory + Print Jobs tabs using NavLink --- frontend/src/App.tsx | 26 ++- frontend/src/pages/PrintJobsPage.tsx | 283 +++++++++++++++++++++++ frontend/src/services/printJobService.ts | 17 ++ frontend/src/types/printJob.ts | 32 +++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/PrintJobsPage.tsx create mode 100644 frontend/src/services/printJobService.ts create mode 100644 frontend/src/types/printJob.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5a8c3c..adf2ad3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,28 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom' +import { Box, Printer } from 'lucide-react' import InventoryPage from './pages/InventoryPage' +import PrintJobsPage from './pages/PrintJobsPage' const queryClient = new QueryClient() +function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ComponentType<{ size?: number }>; label: string }) { + return ( + + `inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ + isActive + ? 'bg-emerald-600/20 text-emerald-300 border border-emerald-700' + : 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50' + }` + } + > + {label} + + ) +} + export default function App() { return ( @@ -12,10 +31,15 @@ export default function App() {
E

Extrudex

+
} /> + } />
diff --git a/frontend/src/pages/PrintJobsPage.tsx b/frontend/src/pages/PrintJobsPage.tsx new file mode 100644 index 0000000..41f6925 --- /dev/null +++ b/frontend/src/pages/PrintJobsPage.tsx @@ -0,0 +1,283 @@ +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Search, Filter, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' +import { fetchPrintJobs } from '../services/printJobService' +import type { PrintJob, PrintJobFilter, PrintJobStatus } from '../types/printJob' + +const PAGE_SIZE = 20 + +type SortField = 'name' | 'started_at' | 'duration_seconds' | 'filament_grams_used' | 'cost_usd' +type SortDir = 'asc' | 'desc' + +// ── Status helpers ──────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record = { + pending: { label: 'Pending', badge: 'bg-slate-700 text-slate-300 border-slate-600', icon: '⏳' }, + printing: { label: 'Printing', badge: 'bg-blue-900/40 text-blue-300 border-blue-700', icon: '🖨️' }, + completed: { label: 'Completed', badge: 'bg-emerald-900/30 text-emerald-300 border-emerald-700', icon: '✅' }, + failed: { label: 'Failed', badge: 'bg-red-900/40 text-red-300 border-red-700', icon: '❌' }, +} + +function StatusBadge({ status }: { status: PrintJobStatus }) { + const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending + return ( + + {cfg.icon} {cfg.label} + + ) +} + +// ── Duration formatter ──────────────────────────────────────────────────────── + +function formatDuration(totalSeconds?: number): string { + if (totalSeconds == null || totalSeconds <= 0) return '—' + const h = Math.floor(totalSeconds / 3600) + const m = Math.floor((totalSeconds % 3600) / 60) + const s = totalSeconds % 60 + if (h > 0) return `${h}h ${m}m` + if (m > 0) return `${m}m ${s}s` + return `${s}s` +} + +function formatDateTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString(undefined, { + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', + }) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function PrintJobsPage() { + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [printerFilter, setPrinterFilter] = useState('') + const [sortBy, setSortBy] = useState('started_at') + const [sortDir, setSortDir] = useState('desc') + const [page, setPage] = useState(0) + + const filter: PrintJobFilter = useMemo(() => ({ + status: statusFilter || undefined, + printer: printerFilter || undefined, + sort_by: sortBy, + sort_dir: sortDir, + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }), [statusFilter, printerFilter, sortBy, sortDir, page]) + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['printJobs', filter], + queryFn: () => fetchPrintJobs(filter), + }) + + const jobs = data?.data ?? [] + const total = data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + // Client-side search (name + printer name) + const filtered = useMemo(() => { + if (!search.trim()) return jobs + const q = search.toLowerCase() + return jobs.filter( + (j: PrintJob) => + j.name.toLowerCase().includes(q) || + j.printer_name.toLowerCase().includes(q) + ) + }, [jobs, search]) + + const handleSort = (field: SortField) => { + if (sortBy === field) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy(field) + setSortDir('asc') + } + setPage(0) + } + + const SortIndicator = ({ field }: { field: SortField }) => { + if (sortBy !== field) return + return {sortDir === 'asc' ? '↑' : '↓'} + } + + return ( +
+ {/* Header */} +
+
+

Print Jobs

+

{total} job(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" + /> +
+ + {/* Status filter */} + + + {/* Printer filter */} + { setPrinterFilter(e.target.value); setPage(0) }} + className="rounded-lg bg-slate-800 border border-slate-700 px-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 min-w-[160px]" + /> + + {/* Filter indicator */} + {(statusFilter || printerFilter) && ( + + + {[statusFilter && `Status: ${statusFilter}`, printerFilter && `Printer: ${printerFilter}`].filter(Boolean).join(' · ')} + + )} +
+ + {/* Loading / Error */} + {isLoading && ( +
+ Loading print jobs… +
+ )} + {error && ( +
+ Failed to load print jobs. + +
+ )} + + {/* Desktop Table */} + {!isLoading && !error && ( + <> +
+ + + + + + + + + + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((job: PrintJob) => ( + + + + + + + + + + ))} + +
handleSort('name')}> + Job + PrinterStatus handleSort('started_at')}> + Started + handleSort('duration_seconds')}> + Duration + handleSort('filament_grams_used')}> + Filament + handleSort('cost_usd')}> + Cost +
No print jobs found.
{job.name}{job.printer_name}{formatDateTime(job.started_at)}{formatDuration(job.duration_seconds)}{job.filament_grams_used != null ? `${job.filament_grams_used.toLocaleString()} g` : '—'}{job.cost_usd != null ? `$${job.cost_usd.toFixed(2)}` : '—'}
+
+ + {/* Mobile Cards */} +
+ {filtered.length === 0 && ( +
No print jobs found.
+ )} + {filtered.map((job: PrintJob) => ( +
+
+
+
{job.name}
+
{job.printer_name}
+
+ +
+ +
+
+ Started +
{formatDateTime(job.started_at)}
+
+
+ Duration +
{formatDuration(job.duration_seconds)}
+
+
+ Filament +
{job.filament_grams_used != null ? `${job.filament_grams_used.toLocaleString()} g` : '—'}
+
+
+ Cost +
{job.cost_usd != null ? `$${job.cost_usd.toFixed(2)}` : '—'}
+
+
+
+ ))} +
+ + {/* Pagination */} +
+ + Showing {total === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total} + +
+ + {page + 1} / {totalPages} + +
+
+ + )} +
+ ) +} diff --git a/frontend/src/services/printJobService.ts b/frontend/src/services/printJobService.ts new file mode 100644 index 0000000..f636176 --- /dev/null +++ b/frontend/src/services/printJobService.ts @@ -0,0 +1,17 @@ +import axios from 'axios' +import type { PrintJob, ListResponse, PrintJobFilter } from '../types/printJob' + +const API_BASE = '/api' + +export async function fetchPrintJobs(filter: PrintJobFilter): Promise> { + const params = new URLSearchParams() + if (filter.status) params.set('status', filter.status) + if (filter.printer) params.set('printer', filter.printer) + 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}/print-jobs?${params.toString()}`) + return res.data +} diff --git a/frontend/src/types/printJob.ts b/frontend/src/types/printJob.ts new file mode 100644 index 0000000..505c0f7 --- /dev/null +++ b/frontend/src/types/printJob.ts @@ -0,0 +1,32 @@ +// Print Job domain types + +export type PrintJobStatus = 'pending' | 'printing' | 'completed' | 'failed' + +export interface PrintJob { + id: number + name: string + printer_name: string + status: PrintJobStatus + started_at: string + duration_seconds?: number + filament_grams_used?: number + cost_usd?: number + created_at: string + updated_at: string +} + +export interface ListResponse { + data: T[] + total: number + limit: number + offset: number +} + +export interface PrintJobFilter { + status?: PrintJobStatus + printer?: string + sort_by?: string + sort_dir?: 'asc' | 'desc' + limit?: number + offset?: number +} -- 2.53.0