@@ -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 < PrintJobStatus , { label : string ; badge : string ; icon : string } > = {
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 (
< span className = { ` inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${ cfg . badge } ` } >
< span className = "text-xs" > { cfg . icon } < / span > { cfg . label }
< / span >
)
}
// ── 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 < PrintJobStatus | '' > ( '' )
const [ printerFilter , setPrinterFilter ] = useState ( '' )
const [ sortBy , setSortBy ] = useState < SortField > ( 'started_at' )
const [ sortDir , setSortDir ] = useState < SortDir > ( '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 < span className = "text-slate-600 ml-1" > ↕ < / span >
return < span className = "text-emerald-400 ml-1" > { sortDir === 'asc' ? '↑' : '↓' } < / span >
}
return (
< div className = "space-y-4" >
{ /* Header */ }
< div className = "flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" >
< div >
< h2 className = "text-xl font-bold text-slate-100" > Print Jobs < / h2 >
< p className = "text-sm text-slate-400" > { total } job ( s ) total < / p >
< / div >
< / div >
{ /* Filters */ }
< div className = "flex flex-col lg:flex-row gap-3" >
{ /* Search */ }
< div className = "relative flex-1" >
< Search size = { 16 } className = "absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" / >
< input
type = "text"
placeholder = "Search by job or printer name…"
value = { search }
onChange = { e = > { 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"
/ >
< / div >
{ /* Status filter */ }
< select
value = { statusFilter }
onChange = { e = > { setStatusFilter ( e . target . value as PrintJobStatus | '' ) ; setPage ( 0 ) } }
className = "rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
< option value = "" > All Statuses < / option >
< option value = "pending" > Pending < / option >
< option value = "printing" > Printing < / option >
< option value = "completed" > Completed < / option >
< option value = "failed" > Failed < / option >
< / select >
{ /* Printer filter */ }
< input
type = "text"
placeholder = "Filter by printer…"
value = { printerFilter }
onChange = { e = > { 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 ) && (
< span className = "inline-flex items-center gap-1 rounded-lg bg-emerald-900/30 border border-emerald-700 px-3 py-2 text-xs text-emerald-300" >
< Filter size = { 14 } / >
{ [ statusFilter && ` Status: ${ statusFilter } ` , printerFilter && ` Printer: ${ printerFilter } ` ] . filter ( Boolean ) . join ( ' · ' ) }
< / span >
) }
< / div >
{ /* Loading / Error */ }
{ isLoading && (
< div className = "flex items-center justify-center py-12 gap-2 text-slate-400" >
< Loader2 size = { 18 } className = "animate-spin" / > Loading print jobs …
< / div >
) }
{ error && (
< div className = "text-center py-12 text-red-400" >
Failed to load print jobs .
< button onClick = { ( ) = > refetch ( ) } className = "ml-2 underline hover:text-red-300" > Retry < / button >
< / div >
) }
{ /* Desktop Table */ }
{ ! isLoading && ! error && (
< >
< div className = "hidden md:block overflow-x-auto rounded-lg border border-slate-700" >
< table className = "w-full text-sm" >
< thead className = "bg-slate-800 text-slate-300" >
< tr >
< th className = "px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100" onClick = { ( ) = > handleSort ( 'name' ) } >
Job < SortIndicator field = "name" / >
< / th >
< th className = "px-4 py-3 text-left font-semibold" > Printer < / th >
< th className = "px-4 py-3 text-left font-semibold" > Status < / th >
< th className = "px-4 py-3 text-left font-semibold cursor-pointer select-none hover:text-slate-100" onClick = { ( ) = > handleSort ( 'started_at' ) } >
Started < SortIndicator field = "started_at" / >
< / th >
< th className = "px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick = { ( ) = > handleSort ( 'duration_seconds' ) } >
Duration < SortIndicator field = "duration_seconds" / >
< / th >
< th className = "px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick = { ( ) = > handleSort ( 'filament_grams_used' ) } >
Filament < SortIndicator field = "filament_grams_used" / >
< / th >
< th className = "px-4 py-3 text-right font-semibold cursor-pointer select-none hover:text-slate-100" onClick = { ( ) = > handleSort ( 'cost_usd' ) } >
Cost < SortIndicator field = "cost_usd" / >
< / th >
< / tr >
< / thead >
< tbody className = "divide-y divide-slate-700" >
{ filtered . length === 0 && (
< tr >
< td colSpan = { 7 } className = "px-4 py-8 text-center text-slate-500" > No print jobs found . < / td >
< / tr >
) }
{ filtered . map ( ( job : PrintJob ) = > (
< tr key = { job . id } className = "bg-slate-800/50 hover:bg-slate-700/50 transition-colors" >
< td className = "px-4 py-3 font-medium text-slate-100" > { job . name } < / td >
< td className = "px-4 py-3 text-slate-300" > { job . printer_name } < / td >
< td className = "px-4 py-3" > < StatusBadge status = { job . status } / > < / td >
< td className = "px-4 py-3 text-slate-300" > { formatDateTime ( job . started_at ) } < / td >
< td className = "px-4 py-3 text-right tabular-nums text-slate-300" > { formatDuration ( job . duration_seconds ) } < / td >
< td className = "px-4 py-3 text-right tabular-nums text-slate-300" > { job . filament_grams_used != null ? ` ${ job . filament_grams_used . toLocaleString ( ) } g ` : '—' } < / td >
< td className = "px-4 py-3 text-right tabular-nums text-slate-300" > { job . cost_usd != null ? ` $ ${ job . cost_usd . toFixed ( 2 ) } ` : '—' } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
{ /* Mobile Cards */ }
< div className = "md:hidden space-y-3" >
{ filtered . length === 0 && (
< div className = "text-center py-12 text-slate-500" > No print jobs found . < / div >
) }
{ filtered . map ( ( job : PrintJob ) = > (
< div key = { job . id } className = "rounded-lg border border-slate-700 bg-slate-800 p-4 space-y-3" >
< div className = "flex items-start justify-between" >
< div >
< div className = "font-semibold text-slate-100" > { job . name } < / div >
< div className = "text-xs text-slate-400 mt-0.5" > { job . printer_name } < / div >
< / div >
< StatusBadge status = { job . status } / >
< / div >
< div className = "grid grid-cols-2 gap-2 text-sm" >
< div >
< span className = "text-slate-500 text-xs" > Started < / span >
< div className = "text-slate-300" > { formatDateTime ( job . started_at ) } < / div >
< / div >
< div >
< span className = "text-slate-500 text-xs" > Duration < / span >
< div className = "text-slate-300 tabular-nums" > { formatDuration ( job . duration_seconds ) } < / div >
< / div >
< div >
< span className = "text-slate-500 text-xs" > Filament < / span >
< div className = "text-slate-300 tabular-nums" > { job . filament_grams_used != null ? ` ${ job . filament_grams_used . toLocaleString ( ) } g ` : '—' } < / div >
< / div >
< div >
< span className = "text-slate-500 text-xs" > Cost < / span >
< div className = "text-slate-300 tabular-nums" > { job . cost_usd != null ? ` $ ${ job . cost_usd . toFixed ( 2 ) } ` : '—' } < / div >
< / div >
< / div >
< / div >
) ) }
< / div >
{ /* Pagination */ }
< div className = "flex items-center justify-between pt-2" >
< span className = "text-sm text-slate-400" >
Showing { total === 0 ? 0 : page * PAGE_SIZE + 1 } – { Math . min ( ( page + 1 ) * PAGE_SIZE , total ) } of { total }
< / span >
< div className = "flex items-center gap-2" >
< button
onClick = { ( ) = > setPage ( p = > Math . max ( 0 , p - 1 ) ) }
disabled = { page === 0 }
className = "p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
< ChevronLeft size = { 16 } / >
< / button >
< span className = "text-sm text-slate-300 tabular-nums" > { page + 1 } / { totalPages } < / span >
< button
onClick = { ( ) = > setPage ( p = > Math . min ( totalPages - 1 , p + 1 ) ) }
disabled = { page >= totalPages - 1 }
className = "p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
< ChevronRight size = { 16 } / >
< / button >
< / div >
< / div >
< / >
) }
< / div >
)
}