import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ArrowLeft, Box, CheckSquare, ChevronDown, Loader2, Move, Pencil, Plus, Search, Square, Trash2, UserMinus, X } from 'lucide-react'; import { api } from '../api/client'; import type { Printer, PrinterLocation } from '../api/client'; import { Button } from '../components/Button'; import { Card, CardContent } from '../components/Card'; import { ConfirmModal } from '../components/ConfirmModal'; import { IconPicker, AVAILABLE_ICONS } from '../components/IconPicker'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; // Matches printers.location / printer_locations.name (VARCHAR(100)). const LOCATION_NAME_MAX_LENGTH = 100; const LOCATION_COLORS = [ '#ef4444', // red '#f97316', // orange '#eab308', // yellow '#22c55e', // green '#14b8a6', // teal '#3b82f6', // blue '#8b5cf6', // violet '#ec4899', // pink '#6b7280', // gray ]; type SortMode = 'name-asc' | 'name-desc' | 'count-asc' | 'count-desc'; const SORT_MODES: SortMode[] = ['name-asc', 'name-desc', 'count-asc', 'count-desc']; // Per-viewer conveniences only; the locations themselves live on the server. const HIDE_EMPTY_KEY = 'printerLocations.hideEmpty'; const SORT_KEY = 'printerLocations.sort'; function readStored(key: string, allowed: readonly T[], fallback: T): T { try { const value = localStorage.getItem(key); return allowed.includes(value as T) ? (value as T) : fallback; } catch { return fallback; } } function writeStored(key: string, value: string) { try { localStorage.setItem(key, value); } catch { // Private mode or storage disabled: the setting just is not remembered. } } const locationOf = (printer: Printer) => printer.location?.trim() || ''; function LocationIcon({ location }: { location: Pick }) { const Icon = AVAILABLE_ICONS.find((i) => i.name === location.icon)?.icon ?? Box; return (
{location.color && (
)}
); } interface PrinterRowProps { printer: Printer; status?: { connected?: boolean; state?: string | null }; selected: boolean; canEdit: boolean; busy: boolean; onToggle: () => void; onMove: () => void; onRemove?: () => void; } function PrinterRow({ printer, status, selected, canEdit, busy, onToggle, onMove, onRemove }: PrinterRowProps) { const { t } = useTranslation(); const state = status?.state; const connected = status?.connected; const label = !connected ? t('printers.status.offline') : state === 'RUNNING' ? t('printers.status.printing') : state === 'PAUSE' ? t('printers.status.paused') : state === 'FINISH' ? t('printers.status.finished') : t('printers.status.idle'); const badge = !connected ? 'bg-gray-500/20 text-gray-400' : state === 'RUNNING' ? 'bg-orange-500/20 text-orange-400' : state === 'PAUSE' ? 'bg-yellow-500/20 text-yellow-400' : state === 'FINISH' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-bambu-gray'; return (
{canEdit && ( )}

{printer.name}

{printer.model || t('printers.status.unknown')}

{label} {canEdit && ( )} {canEdit && onRemove && ( )}
); } interface LocationDialogProps { initial?: PrinterLocation; saving: boolean; onSave: (value: { name: string; icon: string; color: string }) => void; onCancel: () => void; } function LocationDialog({ initial, saving, onSave, onCancel }: LocationDialogProps) { const { t } = useTranslation(); const [name, setName] = useState(initial?.name ?? ''); const [icon, setIcon] = useState(initial?.icon ?? ''); const [color, setColor] = useState(initial?.color ?? ''); const canSave = name.trim().length > 0 && !saving; const save = () => canSave && onSave({ name: name.trim(), icon, color }); return (

{initial ? t('printers.locations.editTitle') : t('printers.locations.createTitle')}

setName(e.target.value)} placeholder={t('printers.locations.namePlaceholder')} aria-label={t('printers.locations.namePlaceholder')} className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') onCancel(); }} />

{t('printers.locations.icon')}

{t('printers.locations.color')}

{LOCATION_COLORS.map((c) => ( )}
); } interface MoveDialogProps { count: number; printerName?: string; locations: PrinterLocation[]; saving: boolean; onMove: (location: string | null) => void; onCancel: () => void; } const NO_LOCATION = '__none__'; function MoveDialog({ count, printerName, locations, saving, onMove, onCancel }: MoveDialogProps) { const { t } = useTranslation(); const [target, setTarget] = useState(''); return (

{count === 1 ? t('printers.locations.moveTitle') : t('printers.locations.moveTitleMany', { count })}

{printerName &&

{printerName}

}
); } export function PrinterLocationsPage() { const queryClient = useQueryClient(); const { t } = useTranslation(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const canEdit = hasPermission('printers:update'); const [search, setSearch] = useState(''); const [hideEmpty, setHideEmpty] = useState(() => readStored(HIDE_EMPTY_KEY, ['true', 'false'], 'false') === 'true'); const [sortMode, setSortMode] = useState(() => readStored(SORT_KEY, SORT_MODES, 'name-asc')); const [showSortMenu, setShowSortMenu] = useState(false); const [expanded, setExpanded] = useState(null); const [selectingLocations, setSelectingLocations] = useState(false); const [selectedLocations, setSelectedLocations] = useState>(new Set()); const [selectedPrinters, setSelectedPrinters] = useState>(new Set()); const [dialog, setDialog] = useState<{ mode: 'create' } | { mode: 'edit'; location: PrinterLocation } | null>(null); const [moving, setMoving] = useState<{ ids: number[]; printerName?: string } | null>(null); const [deleting, setDeleting] = useState(null); useEffect(() => writeStored(HIDE_EMPTY_KEY, String(hideEmpty)), [hideEmpty]); useEffect(() => writeStored(SORT_KEY, sortMode), [sortMode]); useEffect(() => { if (selectingLocations) setExpanded(null); }, [selectingLocations]); const { data: printers, isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters }); const { data: locations, isLoading: locationsLoading } = useQuery({ queryKey: ['printer-locations'], queryFn: api.getPrinterLocations, }); // Same query the printer cards use, so the cache and the WebSocket updates // are shared rather than polled twice. const statusQueries = useQueries({ queries: (printers ?? []).map((p) => ({ queryKey: ['printerStatus', p.id], queryFn: () => api.getPrinterStatus(p.id), refetchInterval: 30000, })), }); const statusById = new Map((printers ?? []).map((p, i) => [p.id, statusQueries[i]?.data])); const printersByLocation = useMemo(() => { const map = new Map(); for (const p of printers ?? []) { const key = locationOf(p); map.set(key, [...(map.get(key) ?? []), p]); } return map; }, [printers]); const ungrouped = printersByLocation.get('') ?? []; const groupedCount = (printers?.length ?? 0) - ungrouped.length; const displayed = useMemo(() => { const q = search.trim().toLowerCase(); const list = (locations ?? []).filter( (loc) => (!q || loc.name.toLowerCase().includes(q)) && (!hideEmpty || loc.printer_count > 0), ); const byName = (a: PrinterLocation, b: PrinterLocation) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); return [...list].sort((a, b) => { switch (sortMode) { case 'name-desc': return byName(b, a); case 'count-asc': return a.printer_count - b.printer_count || byName(a, b); case 'count-desc': return b.printer_count - a.printer_count || byName(a, b); default: return byName(a, b); } }); }, [locations, search, hideEmpty, sortMode]); const refresh = () => { queryClient.invalidateQueries({ queryKey: ['printer-locations'] }); queryClient.invalidateQueries({ queryKey: ['printers'] }); }; const fail = (error: unknown) => showToast(t('printers.locations.failed', { error: error instanceof Error ? error.message : String(error) }), 'error'); const saveMutation = useMutation({ mutationFn: ({ value, location }: { value: { name: string; icon: string; color: string }; location?: PrinterLocation }) => location ? api.updatePrinterLocation({ name: location.name, ...(value.name !== location.name ? { new_name: value.name } : {}), icon: value.icon || null, color: value.color || null, }) : api.createPrinterLocation({ name: value.name, icon: value.icon || null, color: value.color || null }), onSuccess: (saved, { location }) => { refresh(); if (location && expanded === location.name) setExpanded(saved.name); showToast(location ? t('printers.locations.saved') : t('printers.locations.created')); setDialog(null); }, onError: fail, }); const moveMutation = useMutation({ mutationFn: ({ ids, location }: { ids: number[]; location: string | null }) => api.assignPrinterLocation(ids, location), onSuccess: ({ moved }) => { refresh(); showToast(t('printers.locations.moved', { count: moved })); setSelectedPrinters(new Set()); setMoving(null); }, onError: fail, }); const deleteMutation = useMutation({ mutationFn: (names: string[]) => api.deletePrinterLocations(names), onSuccess: ({ deleted }) => { refresh(); showToast(t('printers.locations.deleted', { count: deleted })); setSelectedLocations(new Set()); setSelectingLocations(false); setDeleting(null); }, onError: (error) => { fail(error); setDeleting(null); }, }); const busy = moveMutation.isPending || deleteMutation.isPending || saveMutation.isPending; const toggle = (set: Set, value: T) => { const next = new Set(set); if (next.has(value)) next.delete(value); else next.add(value); return next; }; if (printersLoading || locationsLoading) { return (
); } const deletingPrinterCount = (deleting ?? []).reduce( (sum, name) => sum + (locations?.find((l) => l.name === name)?.printer_count ?? 0), 0, ); const sortLabel = (mode: SortMode) => ({ 'name-asc': t('printers.locations.sortNameAsc'), 'name-desc': t('printers.locations.sortNameDesc'), 'count-asc': t('printers.locations.sortCountAsc'), 'count-desc': t('printers.locations.sortCountDesc'), })[mode]; const renderPrinter = (printer: Printer, removable: boolean) => ( setSelectedPrinters(toggle(selectedPrinters, printer.id))} onMove={() => setMoving({ ids: [printer.id], printerName: printer.name })} onRemove={removable ? () => moveMutation.mutate({ ids: [printer.id], location: null }) : undefined} /> ); const allUngroupedSelected = ungrouped.length > 0 && ungrouped.every((p) => selectedPrinters.has(p.id)); return (
{t('printers.locations.backToPrinters')}

{t('printers.locations.title')}

{t('printers.locations.subtitle', { grouped: groupedCount, ungrouped: ungrouped.length })}

setSearch(e.target.value)} placeholder={t('printers.locations.search')} aria-label={t('printers.locations.search')} className="w-full pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" />
{canEdit && ( )}
{showSortMenu && ( <>
setShowSortMenu(false)} />
{SORT_MODES.map((mode) => ( ))}
)}
{canEdit && ( )}
{displayed.length === 0 && ungrouped.length === 0 ? ( {search ? t('printers.locations.noResults') : t('printers.locations.none')} ) : (
{displayed.map((loc) => { const isExpanded = expanded === loc.name; const isSelected = selectedLocations.has(loc.name); return (
{canEdit && !selectingLocations && (
)}
{isExpanded && (
{(printersByLocation.get(loc.name) ?? []).length === 0 ? (

{t('printers.locations.noPrinters')}

) : ( (printersByLocation.get(loc.name) ?? []).map((p) => renderPrinter(p, true)) )}
)}
); })} {ungrouped.length > 0 && (

{t('printers.locations.ungroupedPrinters', { count: ungrouped.length })}

{canEdit && !selectingLocations && ( )}
{ungrouped.map((p) => renderPrinter(p, false))}
)}
)} {canEdit && !selectingLocations && selectedPrinters.size > 0 && (
{t('printers.locations.selected', { count: selectedPrinters.size })}
)} {selectingLocations && selectedLocations.size > 0 && (
{t('printers.locations.selected', { count: selectedLocations.size })}
)} {dialog && ( saveMutation.mutate({ value, location: dialog.mode === 'edit' ? dialog.location : undefined })} onCancel={() => setDialog(null)} /> )} {moving && ( moveMutation.mutate({ ids: moving.ids, location })} onCancel={() => setMoving(null)} /> )} {deleting && ( deleteMutation.mutate(deleting)} onCancel={() => setDeleting(null)} /> )}
); }