import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, ChevronDown, ChevronRight, Loader2, MapPin, Pencil, Printer as PrinterIcon, Save, Search, Users, X, } from 'lucide-react'; import { api } from '../api/client'; import type { Group, Printer } from '../api/client'; import { Button } from './Button'; import { Card } from './Card'; import { Toggle } from './Toggle'; import { useToast } from '../contexts/ToastContext'; /** * Which printers each group may use (#1727). * * A limited group reaches the printers picked for it plus every printer whose * location it was given, including printers added there later. Edits collect * as per-group drafts, from either view, and are saved together. */ interface Draft { restrict: boolean; printerIds: number[]; locations: string[]; } type View = 'groups' | 'printers'; type GroupFilter = 'all' | 'limited' | 'open'; type AccessFilter = 'all' | 'with' | 'without'; const NO_LOCATION = '__none__'; // Past this many printers, location sections start collapsed const AUTO_COLLAPSE_OVER = 60; const inputClass = 'px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green'; const isAdministrators = (group: Group) => group.is_system && group.name === 'Administrators'; const draftOf = (group: Group): Draft => ({ restrict: group.restrict_printers, printerIds: [...group.printer_ids].sort((a, b) => a - b), locations: [...(group.locations ?? [])].sort(), }); const sameDraft = (a: Draft, b: Draft) => a.restrict === b.restrict && a.printerIds.length === b.printerIds.length && a.printerIds.every((id, i) => id === b.printerIds[i]) && a.locations.length === b.locations.length && a.locations.every((loc, i) => loc === b.locations[i]); const locationKey = (printer: Printer) => printer.location || NO_LOCATION; /** How a draft reaches a printer: through its location, picked, or not at all. */ function reachOf(draft: Draft, printer: Printer): 'location' | 'picked' | null { if (printer.location && draft.locations.includes(printer.location)) return 'location'; if (draft.printerIds.includes(printer.id)) return 'picked'; return null; } export function PrinterAccessSettings() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [searchParams, setSearchParams] = useSearchParams(); const view: View = searchParams.get('view') === 'printers' ? 'printers' : 'groups'; const selectedGroupId = Number(searchParams.get('group')) || null; const focusPrinterId = Number(searchParams.get('printer')) || null; const setParams = (changes: Record) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); for (const [key, value] of Object.entries(changes)) { if (value === null) next.delete(key); else next.set(key, value); } return next; }, { replace: true } ); }; const [drafts, setDrafts] = useState>({}); const [groupSearch, setGroupSearch] = useState(''); const [groupFilter, setGroupFilter] = useState('all'); const [printerSearch, setPrinterSearch] = useState(''); const [locationFilter, setLocationFilter] = useState('all'); const [modelFilter, setModelFilter] = useState('all'); const [accessFilter, setAccessFilter] = useState('all'); const [sectionOpen, setSectionOpen] = useState>({}); const { data: groups, isLoading: groupsLoading } = useQuery({ queryKey: ['groups'], queryFn: () => api.getGroups(), }); const { data: printers, isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); const { data: users } = useQuery({ queryKey: ['users'], queryFn: () => api.getUsers(), }); const draftFor = (group: Group): Draft => drafts[group.id] ?? draftOf(group); const updateDraft = (group: Group, change: (draft: Draft) => Draft) => { setDrafts((prev) => { const next = change(prev[group.id] ?? draftOf(group)); const normalized: Draft = { restrict: next.restrict, printerIds: [...new Set(next.printerIds)].sort((a, b) => a - b), locations: [...new Set(next.locations)].sort(), }; const rest = { ...prev }; if (sameDraft(normalized, draftOf(group))) delete rest[group.id]; else rest[group.id] = normalized; return rest; }); }; const dirtyGroups = useMemo( () => (groups ?? []).filter((g) => drafts[g.id] !== undefined), [groups, drafts] ); const isDirty = dirtyGroups.length > 0; useEffect(() => { if (!isDirty) return; const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, [isDirty]); const saveMutation = useMutation({ mutationFn: async () => { // One group after the other, so a refusal leaves the rest as drafts for (const group of dirtyGroups) { const draft = drafts[group.id]; await api.updateGroup(group.id, { restrict_printers: draft.restrict, printer_ids: draft.printerIds, locations: draft.locations, }); setDrafts((prev) => { const rest = { ...prev }; delete rest[group.id]; return rest; }); } }, onSuccess: () => showToast(t('printerAccess.saved')), onError: (error: Error) => showToast(error.message, 'error'), onSettled: () => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['group'] }); }, }); const allPrinters = useMemo( () => [...(printers ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [printers] ); const locations = useMemo(() => { const names = new Set(); for (const p of allPrinters) if (p.location) names.add(p.location); return [...names].sort((a, b) => a.localeCompare(b)); }, [allPrinters]); const hasUnlocated = allPrinters.some((p) => !p.location); const models = useMemo(() => { const names = new Set(); for (const p of allPrinters) if (p.model) names.add(p.model); return [...names].sort(); }, [allPrinters]); const printersByLocation = useMemo(() => { const map = new Map(); for (const p of allPrinters) { const key = locationKey(p); map.set(key, [...(map.get(key) ?? []), p]); } return map; }, [allPrinters]); // Printers left after search, location and model filters (not the access filter) const baseFiltered = useMemo(() => { const q = printerSearch.trim().toLowerCase(); return allPrinters.filter((p) => { if (locationFilter !== 'all' && locationKey(p) !== locationFilter) return false; if (modelFilter !== 'all' && p.model !== modelFilter) return false; if (!q) return true; return ( p.name.toLowerCase().includes(q) || (p.model ?? '').toLowerCase().includes(q) || p.serial_number.toLowerCase().includes(q) || (p.location ?? '').toLowerCase().includes(q) ); }); }, [allPrinters, printerSearch, locationFilter, modelFilter]); const sortedGroups = useMemo(() => [...(groups ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [groups]); const groupQuery = groupSearch.trim().toLowerCase(); const visibleGroups = sortedGroups.filter((g) => { if (groupQuery && !g.name.toLowerCase().includes(groupQuery)) return false; const limited = !isAdministrators(g) && draftFor(g).restrict; if (groupFilter === 'limited') return limited; if (groupFilter === 'open') return !limited; return true; }); const limitedGroups = sortedGroups.filter((g) => !isAdministrators(g) && draftFor(g).restrict); const reachCount = (draft: Draft) => allPrinters.filter((p) => reachOf(draft, p) !== null).length; if (groupsLoading || printersLoading) { return (
); } const selectedGroup = sortedGroups.find((g) => g.id === selectedGroupId) ?? null; const filterBar = (withAccessFilter: boolean) => (
setPrinterSearch(e.target.value)} placeholder={t('printerAccess.searchPrinters')} aria-label={t('printerAccess.searchPrinters')} className={`${inputClass} w-full pl-9`} />
{models.length > 1 && ( )} {withAccessFilter && ( )}
); const renderGroupList = () => (
setGroupSearch(e.target.value)} placeholder={t('printerAccess.searchGroups')} aria-label={t('printerAccess.searchGroups')} className={`${inputClass} w-full pl-9`} />
{(['all', 'limited', 'open'] as const).map((f) => ( ))}
{visibleGroups.length === 0 ? (

{t('printerAccess.noGroupsMatch')}

) : ( visibleGroups.map((g) => { const draft = draftFor(g); const admins = isAdministrators(g); const summary = admins ? t('printerAccess.alwaysAll') : draft.restrict ? t('printerAccess.reachShort', { count: reachCount(draft), total: allPrinters.length }) : t('printerAccess.notLimited'); return ( ); }) )}
); const renderGroupEditor = () => { if (!selectedGroup) { return ( {t('printerAccess.pickGroup')} ); } const group = selectedGroup; const draft = draftFor(group); const reached = reachCount(draft); const shown = baseFiltered.filter((p) => { const reach = reachOf(draft, p); if (accessFilter === 'with') return reach !== null; if (accessFilter === 'without') return reach === null; return true; }); const shownByLocation = new Map(); for (const p of shown) { const key = locationKey(p); shownByLocation.set(key, [...(shownByLocation.get(key) ?? []), p]); } const sectionKeys = [...shownByLocation.keys()].sort((a, b) => a === NO_LOCATION ? 1 : b === NO_LOCATION ? -1 : a.localeCompare(b) ); // Locations given to the group that no printer carries any more (renamed, emptied) const staleLocations = draft.locations.filter((loc) => !printersByLocation.has(loc)); const autoCollapsed = shown.length > AUTO_COLLAPSE_OVER && !printerSearch.trim(); const isOpen = (key: string) => sectionOpen[key] ?? !autoCollapsed; const toggleLocation = (loc: string, on: boolean) => updateDraft(group, (d) => ({ ...d, locations: on ? [...d.locations, loc] : d.locations.filter((l) => l !== loc), })); const togglePrinter = (id: number, on: boolean) => updateDraft(group, (d) => ({ ...d, printerIds: on ? [...d.printerIds, id] : d.printerIds.filter((p) => p !== id), })); const pickShown = (on: boolean) => { const ids = shown.filter((p) => reachOf(draft, p) !== 'location').map((p) => p.id); updateDraft(group, (d) => ({ ...d, printerIds: on ? [...d.printerIds, ...ids] : d.printerIds.filter((id) => !ids.includes(id)), })); }; return (

{group.name}

{t('printerAccess.memberCount', { count: group.user_count })}

{t('printerAccess.editGroup')}
{isAdministrators(group) ? (

{t('printerAccess.adminsSeeAll')}

) : ( <>

{t('printerAccess.limit')}

{t('printerAccess.limitHint')}

updateDraft(group, (d) => ({ ...d, restrict: on }))} />
{draft.restrict && ( <>
{t('printerAccess.reach', { count: reached, total: allPrinters.length })} {reached === 0 && ( {t('printerAccess.noneGranted')} )}
{allPrinters.length === 0 ? (

{t('printerAccess.noPrinters')}

) : ( <> {filterBar(true)}
{staleLocations.map((loc) => (
{loc} ({t('printerAccess.emptyLocation')})
))} {sectionKeys.length === 0 ? (

{t('printerAccess.noPrintersMatch')}

) : (
{sectionKeys.map((key) => { const rows = shownByLocation.get(key) ?? []; const all = printersByLocation.get(key) ?? []; const inLocation = all.filter((p) => reachOf(draft, p) !== null).length; const isLocation = key !== NO_LOCATION; const granted = isLocation && draft.locations.includes(key); const open = isOpen(key); return (
{isLocation && ( )}
{open && (
{rows.map((p) => { const reach = reachOf(draft, p); return ( ); })}
)}
); })}
)} )} )} )}
); }; const renderByPrinter = () => { const limitedIds = new Set(limitedGroups.map((g) => g.id)); // Who sees every printer whatever the groups below say const openUsers = (users ?? []).filter( (u) => u.is_active && !u.is_admin && !u.groups.some((g) => limitedIds.has(g.id)) ); const rows = focusPrinterId ? allPrinters.filter((p) => p.id === focusPrinterId) : baseFiltered; return (

{t('printerAccess.alsoVisible')}

{users && (

0 ? 'text-yellow-700 dark:text-yellow-400' : 'text-bambu-gray'}> {openUsers.length > 0 ? t('printerAccess.openUsers', { names: openUsers .slice(0, 10) .map((u) => u.username) .join(', ') + (openUsers.length > 10 ? ` +${openUsers.length - 10}` : ''), }) : t('printerAccess.noOpenUsers')}

)}
{limitedGroups.length === 0 && (

{t('printerAccess.noLimitedGroups')}

)} {focusPrinterId ? (
{t('printerAccess.showingOne')}
) : ( filterBar(false) )} {rows.length === 0 ? (

{allPrinters.length === 0 ? t('printerAccess.noPrinters') : t('printerAccess.noPrintersMatch')}

) : (
{rows.map((p) => { const reaching = limitedGroups .map((g) => ({ group: g, reach: reachOf(draftFor(g), p) })) .filter((r) => r.reach !== null); const addable = limitedGroups.filter((g) => reachOf(draftFor(g), p) === null); return (
{p.name}
{[p.model, p.location || t('printerAccess.noLocation')].filter(Boolean).join(' · ')}
{reaching.map(({ group: g, reach }) => reach === 'location' ? ( ) : ( {g.name} ) )} {reaching.length === 0 && limitedGroups.length > 0 && ( {t('printerAccess.noLimitedGroupReaches')} )}
{addable.length > 0 && ( )}
); })}
)}
); }; return (

{t('printerAccess.intro')}

{(['groups', 'printers'] as const).map((v) => ( ))}
{view === 'groups' ? (
{renderGroupList()} {renderGroupEditor()}
) : ( renderByPrinter() )} {isDirty && (
{t('printerAccess.changedGroups', { names: dirtyGroups.map((g) => g.name).join(', ') })}
)}
); }