diff --git a/frontend/src/components/BulkPrinterToolbar.tsx b/frontend/src/components/BulkPrinterToolbar.tsx index 301279967..aa4203fc4 100644 --- a/frontend/src/components/BulkPrinterToolbar.tsx +++ b/frontend/src/components/BulkPrinterToolbar.tsx @@ -108,7 +108,7 @@ export function BulkPrinterToolbar({ printing: t('printers.status.printing'), paused: t('printers.status.paused', 'Paused'), finished: t('printers.status.finished', 'Finished'), - idle: t('printers.status.available'), + idle: t('printers.status.idle'), error: t('printers.status.problem'), offline: t('printers.status.offline'), }; diff --git a/frontend/src/components/Collapsible.tsx b/frontend/src/components/Collapsible.tsx index e19a0558f..37f4c1bc1 100644 --- a/frontend/src/components/Collapsible.tsx +++ b/frontend/src/components/Collapsible.tsx @@ -8,12 +8,21 @@ interface CollapsibleProps { defaultOpen?: boolean; className?: string; summaryClassName?: string; + /** When provided, the component is controlled — parent owns the open state. */ + open?: boolean; + /** Called when the user clicks the toggle. Use with `open` for controlled mode. */ + onToggle?: (open: boolean) => void; } /** - * Lightweight disclosure used for densifying the Settings page. - * Renders a clickable summary row and animates open/close via a simple - * display swap (no height animation — keeps it snappy and layout-stable). + * Lightweight disclosure widget. + * Renders a clickable summary row and conditionally displays children. + * + * The toggle region is a plain
with role="button" so that the summary + * slot may safely contain interactive elements (buttons, links) without + * nesting a - {open &&
{children}
} +
+ {isOpen &&
{children}
} ); } diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 5e5c0a78c..9420eea54 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -83,6 +83,7 @@ import { PrinterInfoModal } from '../components/PrinterInfoModal'; import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers'; import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer'; import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; +import { Collapsible } from '../components/Collapsible'; import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'; // Color names resolve via getColorName() which reads the backend color_catalog @@ -988,6 +989,33 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { type SortOption = 'name' | 'status' | 'model' | 'location'; type ViewMode = 'expanded' | 'compact'; +const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline']; + +const STATUS_GROUP_META: Record = { + error: { labelKey: 'printers.status.problem', dot: 'bg-status-error' }, + printing: { labelKey: 'printers.status.printing', dot: 'bg-bambu-green animate-pulse' }, + paused: { labelKey: 'printers.status.paused', dot: 'bg-status-warning' }, + finished: { labelKey: 'printers.status.finished', dot: 'bg-blue-400' }, + idle: { labelKey: 'printers.status.idle', dot: 'bg-bambu-green' }, + offline: { labelKey: 'printers.status.offline', dot: 'bg-gray-400' }, +}; + +/** Classify a printer into one of the UI status buckets. */ +function classifyPrinterStatus( + status: { connected: boolean; state: string | null; hms_errors?: HMSError[] } | undefined, +): PrinterState { + if (!status?.connected) return 'offline'; + const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; + if (hmsErrors.length > 0) return 'error'; + switch (status.state) { + case 'RUNNING': return 'printing'; + case 'PAUSE': return 'paused'; + case 'FINISH': return 'finished'; + case 'FAILED': return 'error'; + default: return 'idle'; + } +} + /** * Get human-readable status display text for a printer. * Uses stg_cur_name for detailed calibration/preparation stages, @@ -5841,6 +5869,12 @@ export function PrintersPage() { const [statusFilter, setStatusFilter] = useState('all'); const [locationFilter, setLocationFilter] = useState('all'); const [statusCacheVersion, setStatusCacheVersion] = useState(0); + const [collapsedSections, setCollapsedSections] = useState>(() => { + try { + const saved = localStorage.getItem('printerCollapsedSections'); + return saved ? JSON.parse(saved) : {}; + } catch { return {}; } + }); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); @@ -6265,15 +6299,7 @@ export function PrintersPage() { const next = new Set(prev); sortedPrinters.forEach(p => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]); - if (!status) return; - switch (state) { - case 'printing': if (status.connected && status.state === 'RUNNING') next.add(p.id); break; - case 'paused': if (status.connected && status.state === 'PAUSE') next.add(p.id); break; - case 'finished': if (status.connected && status.state === 'FINISH') next.add(p.id); break; - case 'idle': if (status.connected && status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED') next.add(p.id); break; - case 'error': if (status.connected && (status.state === 'FAILED' || (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0))) next.add(p.id); break; - case 'offline': if (!status.connected) next.add(p.id); break; - } + if (classifyPrinterStatus(status) === state) next.add(p.id); }); return next; }); @@ -6289,18 +6315,53 @@ export function PrintersPage() { setIsSelectionMode(true); }, [sortedPrinters]); - // Group printers by location when sorted by location + const selectByModel = useCallback((model: string) => { + setSelectedPrinterIds(prev => { + const next = new Set(prev); + sortedPrinters.filter(p => (p.model || 'Unknown') === model).forEach(p => next.add(p.id)); + return next; + }); + setIsSelectionMode(true); + }, [sortedPrinters]); + + const toggleSectionCollapse = useCallback((key: string) => { + setCollapsedSections(prev => { + const next = { ...prev, [key]: !prev[key] }; + try { localStorage.setItem('printerCollapsedSections', JSON.stringify(next)); } catch { /* quota exceeded / private mode */ } + return next; + }); + }, []); + + // Group printers when sorted by location, status, or model const groupedPrinters = useMemo(() => { - if (sortBy !== 'location') return null; + if (sortBy === 'name') return null; const groups: Record = {}; - sortedPrinters.forEach(printer => { - const location = printer.location || 'Ungrouped'; - if (!groups[location]) groups[location] = []; - groups[location].push(printer); - }); + + if (sortBy === 'location') { + sortedPrinters.forEach(printer => { + const location = printer.location || 'Ungrouped'; + if (!groups[location]) groups[location] = []; + groups[location].push(printer); + }); + } else if (sortBy === 'model') { + sortedPrinters.forEach(printer => { + const model = printer.model || 'Unknown'; + if (!groups[model]) groups[model] = []; + groups[model].push(printer); + }); + } else if (sortBy === 'status') { + sortedPrinters.forEach(printer => { + const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]); + const group = classifyPrinterStatus(status); + if (!groups[group]) groups[group] = []; + groups[group].push(printer); + }); + } + return groups; - }, [sortBy, sortedPrinters]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- classifyPrinterStatus & filterKnownHMSErrors are stable module-level functions, not reactive deps; statusCacheVersion forces recompute on WebSocket status updates + }, [sortBy, sortedPrinters, queryClient, statusCacheVersion]); return (
@@ -6525,59 +6586,92 @@ export function PrintersPage() { ) : groupedPrinters ? ( - /* Grouped by location view */ + /* Grouped view (location, status, or model) */
- {Object.entries(groupedPrinters).map(([location, locationPrinters]) => ( -
-

- - {location} - ({locationPrinters.length}) - {selectionMode && ( - - )} -

-
= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> - {locationPrinters.map((printer) => ( - unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} - timeFormat={settings?.time_format || 'system'} - cameraViewMode={settings?.camera_view_mode || 'window'} - onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))} - checkPrinterFirmware={settings?.check_printer_firmware !== false} - dryingPresets={effectiveDryingPresets} - requirePlateClear={settings?.require_plate_clear === true} - selectionMode={selectionMode} - isSelected={selectedPrinterIds.has(printer.id)} - onToggleSelect={toggleSelect} - /> - ))} -
-
- ))} + {(() => { + const keys = sortBy === 'status' + ? STATUS_GROUP_ORDER.filter(k => groupedPrinters[k]?.length > 0) + : Object.keys(groupedPrinters); + // For status grouping, asc/desc flips the fixed priority order + // (asc = error→offline, desc = offline→error). This matches the + // sort-toggle behaviour for other groupings. + return (sortAsc ? keys : [...keys].reverse()); + })().map((groupKey) => { + const groupPrinters = groupedPrinters[groupKey]; + const collapseKey = `${sortBy}:${groupKey}`; + const isOpen = !collapsedSections[collapseKey]; + + const dot = sortBy === 'status' + ? STATUS_GROUP_META[groupKey]?.dot || 'bg-bambu-green' + : 'bg-bambu-green'; + const label = sortBy === 'status' + ? t(STATUS_GROUP_META[groupKey]?.labelKey || groupKey) + : groupKey; + + return ( + toggleSectionCollapse(collapseKey)} + summaryClassName="py-1" + summary={ +

+ + {label} + ({groupPrinters.length}) + {selectionMode && ( + + )} +

+ } + > +
= 3 ? 'gap-6' : ''} ${getGridClasses()}`}> + {groupPrinters.map((printer) => ( + unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })} + timeFormat={settings?.time_format || 'system'} + cameraViewMode={settings?.camera_view_mode || 'window'} + onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))} + checkPrinterFirmware={settings?.check_printer_firmware !== false} + dryingPresets={effectiveDryingPresets} + requirePlateClear={settings?.require_plate_clear === true} + selectionMode={selectionMode} + isSelected={selectedPrinterIds.has(printer.id)} + onToggleSelect={toggleSelect} + /> + ))} +
+
+ ); + })}
) : ( /* Regular grid view */