| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787 |
- 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<string, string | null>) => {
- 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<Record<number, Draft>>({});
- const [groupSearch, setGroupSearch] = useState('');
- const [groupFilter, setGroupFilter] = useState<GroupFilter>('all');
- const [printerSearch, setPrinterSearch] = useState('');
- const [locationFilter, setLocationFilter] = useState('all');
- const [modelFilter, setModelFilter] = useState('all');
- const [accessFilter, setAccessFilter] = useState<AccessFilter>('all');
- const [sectionOpen, setSectionOpen] = useState<Record<string, boolean>>({});
- 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<string>();
- 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<string>();
- for (const p of allPrinters) if (p.model) names.add(p.model);
- return [...names].sort();
- }, [allPrinters]);
- const printersByLocation = useMemo(() => {
- const map = new Map<string, Printer[]>();
- 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 (
- <div className="flex items-center justify-center py-16">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- </div>
- );
- }
- const selectedGroup = sortedGroups.find((g) => g.id === selectedGroupId) ?? null;
- const filterBar = (withAccessFilter: boolean) => (
- <div className="flex flex-wrap items-center gap-2">
- <div className="relative flex-1 min-w-[12rem]">
- <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
- <input
- type="text"
- value={printerSearch}
- onChange={(e) => setPrinterSearch(e.target.value)}
- placeholder={t('printerAccess.searchPrinters')}
- aria-label={t('printerAccess.searchPrinters')}
- className={`${inputClass} w-full pl-9`}
- />
- </div>
- <select
- value={locationFilter}
- onChange={(e) => setLocationFilter(e.target.value)}
- aria-label={t('printerAccess.allLocations')}
- className={inputClass}
- >
- <option value="all">{t('printerAccess.allLocations')}</option>
- {locations.map((loc) => (
- <option key={loc} value={loc}>
- {loc}
- </option>
- ))}
- {hasUnlocated && <option value={NO_LOCATION}>{t('printerAccess.noLocation')}</option>}
- </select>
- {models.length > 1 && (
- <select
- value={modelFilter}
- onChange={(e) => setModelFilter(e.target.value)}
- aria-label={t('printerAccess.allModels')}
- className={inputClass}
- >
- <option value="all">{t('printerAccess.allModels')}</option>
- {models.map((m) => (
- <option key={m} value={m}>
- {m}
- </option>
- ))}
- </select>
- )}
- {withAccessFilter && (
- <select
- value={accessFilter}
- onChange={(e) => setAccessFilter(e.target.value as AccessFilter)}
- aria-label={t('printerAccess.accessFilter')}
- className={inputClass}
- >
- <option value="all">{t('printerAccess.accessAll')}</option>
- <option value="with">{t('printerAccess.accessWith')}</option>
- <option value="without">{t('printerAccess.accessWithout')}</option>
- </select>
- )}
- </div>
- );
- const renderGroupList = () => (
- <Card className="lg:w-80 shrink-0 flex flex-col lg:max-h-[calc(100vh-14rem)]">
- <div className="p-3 space-y-2 border-b border-bambu-dark-tertiary">
- <div className="relative">
- <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
- <input
- type="text"
- value={groupSearch}
- onChange={(e) => setGroupSearch(e.target.value)}
- placeholder={t('printerAccess.searchGroups')}
- aria-label={t('printerAccess.searchGroups')}
- className={`${inputClass} w-full pl-9`}
- />
- </div>
- <div className="flex gap-1">
- {(['all', 'limited', 'open'] as const).map((f) => (
- <button
- key={f}
- type="button"
- onClick={() => setGroupFilter(f)}
- className={`flex-1 px-2 py-1 text-xs rounded-md transition-colors ${
- groupFilter === f
- ? 'bg-bambu-green/20 text-bambu-green'
- : 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
- }`}
- >
- {t(`printerAccess.groupFilter.${f}`)}
- </button>
- ))}
- </div>
- </div>
- <div className="overflow-y-auto p-1">
- {visibleGroups.length === 0 ? (
- <p className="text-sm text-bambu-gray p-3">{t('printerAccess.noGroupsMatch')}</p>
- ) : (
- 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 (
- <button
- key={g.id}
- type="button"
- onClick={() => setParams({ group: String(g.id) })}
- className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
- g.id === selectedGroupId ? 'bg-bambu-dark-tertiary' : 'hover:bg-bambu-dark-tertiary/60'
- }`}
- >
- <div className="flex items-center gap-2">
- <span className="text-sm text-white truncate flex-1">{g.name}</span>
- {drafts[g.id] && (
- <span className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" title={t('printerAccess.unsaved')} />
- )}
- </div>
- <div className="flex items-center gap-2 text-xs text-bambu-gray mt-0.5">
- <Users className="w-3 h-3 shrink-0" />
- <span>{g.user_count}</span>
- <span>·</span>
- <span className={!admins && draft.restrict ? 'text-bambu-green' : ''}>{summary}</span>
- </div>
- </button>
- );
- })
- )}
- </div>
- </Card>
- );
- const renderGroupEditor = () => {
- if (!selectedGroup) {
- return (
- <Card className="flex-1 p-8 text-center text-sm text-bambu-gray">{t('printerAccess.pickGroup')}</Card>
- );
- }
- 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<string, Printer[]>();
- 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 (
- <Card className="flex-1 min-w-0">
- <div className="p-4 space-y-4">
- <div className="flex items-start justify-between gap-3">
- <div className="min-w-0">
- <h3 className="text-white font-medium truncate">{group.name}</h3>
- <p className="text-xs text-bambu-gray mt-0.5">
- {t('printerAccess.memberCount', { count: group.user_count })}
- </p>
- </div>
- <Link
- to={`/groups/${group.id}/edit`}
- className="flex items-center gap-1.5 text-xs text-bambu-gray hover:text-white shrink-0"
- >
- <Pencil className="w-3.5 h-3.5" />
- {t('printerAccess.editGroup')}
- </Link>
- </div>
- {isAdministrators(group) ? (
- <p className="text-sm text-bambu-gray">{t('printerAccess.adminsSeeAll')}</p>
- ) : (
- <>
- <div className="flex items-start justify-between gap-4">
- <div>
- <p className="text-sm text-white">{t('printerAccess.limit')}</p>
- <p className="text-xs text-bambu-gray mt-1">{t('printerAccess.limitHint')}</p>
- </div>
- <Toggle
- checked={draft.restrict}
- onChange={(on) => updateDraft(group, (d) => ({ ...d, restrict: on }))}
- />
- </div>
- {draft.restrict && (
- <>
- <div className="flex flex-wrap items-center gap-3">
- <span className="text-sm text-bambu-gray">
- {t('printerAccess.reach', { count: reached, total: allPrinters.length })}
- </span>
- {reached === 0 && (
- <span className="flex items-center gap-1.5 text-xs text-yellow-700 dark:text-yellow-400">
- <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
- {t('printerAccess.noneGranted')}
- </span>
- )}
- </div>
- {allPrinters.length === 0 ? (
- <p className="text-sm text-bambu-gray">{t('printerAccess.noPrinters')}</p>
- ) : (
- <>
- {filterBar(true)}
- <div className="flex items-center gap-2">
- <Button size="sm" variant="ghost" onClick={() => pickShown(true)} disabled={shown.length === 0}>
- {t('printerAccess.tickShown')}
- </Button>
- <Button size="sm" variant="ghost" onClick={() => pickShown(false)} disabled={shown.length === 0}>
- {t('printerAccess.untickShown')}
- </Button>
- </div>
- {staleLocations.map((loc) => (
- <div
- key={`stale-${loc}`}
- className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-dashed border-bambu-dark-tertiary"
- >
- <span className="flex items-center gap-2 text-sm text-bambu-gray min-w-0">
- <MapPin className="w-4 h-4 shrink-0" />
- <span className="truncate">{loc}</span>
- <span className="text-xs">({t('printerAccess.emptyLocation')})</span>
- </span>
- <label className="flex items-center gap-2 text-xs text-bambu-gray cursor-pointer shrink-0">
- <input
- type="checkbox"
- checked
- onChange={() => toggleLocation(loc, false)}
- aria-label={t('printerAccess.wholeLocationHint', { location: loc })}
- className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
- />
- {t('printerAccess.wholeLocation')}
- </label>
- </div>
- ))}
- {sectionKeys.length === 0 ? (
- <p className="text-sm text-bambu-gray py-4 text-center">{t('printerAccess.noPrintersMatch')}</p>
- ) : (
- <div className="space-y-2">
- {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 (
- <div key={key} className="rounded-lg border border-bambu-dark-tertiary">
- <div className="flex items-center gap-3 px-3 py-2">
- <button
- type="button"
- onClick={() => setSectionOpen((prev) => ({ ...prev, [key]: !open }))}
- className="flex items-center gap-2 flex-1 min-w-0 text-left"
- aria-expanded={open}
- >
- {open ? (
- <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
- ) : (
- <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
- )}
- <MapPin className="w-4 h-4 text-bambu-gray shrink-0" />
- <span className="text-sm text-white truncate">
- {isLocation ? key : t('printerAccess.noLocation')}
- </span>
- <span className="text-xs text-bambu-gray tabular-nums shrink-0">
- {inLocation}/{all.length}
- </span>
- </button>
- {isLocation && (
- <label
- className="flex items-center gap-2 text-xs text-bambu-gray cursor-pointer shrink-0"
- title={t('printerAccess.wholeLocationHint', { location: key })}
- >
- <input
- type="checkbox"
- checked={granted}
- onChange={(e) => toggleLocation(key, e.target.checked)}
- aria-label={t('printerAccess.wholeLocationHint', { location: key })}
- className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
- />
- {t('printerAccess.wholeLocation')}
- </label>
- )}
- </div>
- {open && (
- <div className="grid grid-cols-1 md:grid-cols-2 gap-1 px-2 pb-2">
- {rows.map((p) => {
- const reach = reachOf(draft, p);
- return (
- <label
- key={p.id}
- className={`flex items-center gap-3 px-2 py-1.5 rounded ${
- reach === 'location' ? 'cursor-default' : 'cursor-pointer hover:bg-bambu-dark-tertiary/60'
- }`}
- >
- <input
- type="checkbox"
- checked={reach !== null}
- disabled={reach === 'location'}
- onChange={(e) => togglePrinter(p.id, e.target.checked)}
- aria-label={p.name}
- className="w-4 h-4 shrink-0 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary disabled:opacity-60"
- />
- <span className="min-w-0 flex-1">
- <span className="block text-sm text-white truncate">{p.name}</span>
- <span className="block text-xs text-bambu-gray truncate">
- {[p.model, p.serial_number].filter(Boolean).join(' · ')}
- </span>
- </span>
- {reach === 'location' && (
- <span className="text-xs text-bambu-green shrink-0">
- {t('printerAccess.viaLocation')}
- </span>
- )}
- </label>
- );
- })}
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
- </>
- )}
- </>
- )}
- </>
- )}
- </div>
- </Card>
- );
- };
- 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 (
- <div className="space-y-3">
- <Card>
- <div className="p-4 space-y-1 text-sm">
- <p className="text-bambu-gray">{t('printerAccess.alsoVisible')}</p>
- {users && (
- <p className={openUsers.length > 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')}
- </p>
- )}
- </div>
- </Card>
- {limitedGroups.length === 0 && (
- <p className="text-sm text-bambu-gray">{t('printerAccess.noLimitedGroups')}</p>
- )}
- {focusPrinterId ? (
- <div className="flex items-center gap-2 text-sm text-bambu-gray">
- <span>{t('printerAccess.showingOne')}</span>
- <Button size="sm" variant="ghost" onClick={() => setParams({ printer: null })}>
- {t('printerAccess.showAll')}
- </Button>
- </div>
- ) : (
- filterBar(false)
- )}
- {rows.length === 0 ? (
- <p className="text-sm text-bambu-gray py-4 text-center">
- {allPrinters.length === 0 ? t('printerAccess.noPrinters') : t('printerAccess.noPrintersMatch')}
- </p>
- ) : (
- <Card>
- <div className="divide-y divide-bambu-dark-tertiary">
- {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 (
- <div key={p.id} className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 px-4 py-3">
- <div className="md:w-64 shrink-0 min-w-0">
- <div className="flex items-center gap-2">
- <PrinterIcon className="w-4 h-4 text-bambu-gray shrink-0" />
- <span className="text-sm text-white truncate">{p.name}</span>
- </div>
- <div className="text-xs text-bambu-gray truncate pl-6">
- {[p.model, p.location || t('printerAccess.noLocation')].filter(Boolean).join(' · ')}
- </div>
- </div>
- <div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
- {reaching.map(({ group: g, reach }) =>
- reach === 'location' ? (
- <button
- key={g.id}
- type="button"
- onClick={() => setParams({ view: null, group: String(g.id), printer: null })}
- title={t('printerAccess.viaLocationTitle', { location: p.location })}
- className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/10 text-bambu-green border border-bambu-green/30"
- >
- <MapPin className="w-3 h-3" />
- {g.name}
- </button>
- ) : (
- <span
- key={g.id}
- className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs bg-bambu-green/20 text-bambu-green"
- >
- {g.name}
- <button
- type="button"
- onClick={() =>
- updateDraft(g, (d) => ({ ...d, printerIds: d.printerIds.filter((id) => id !== p.id) }))
- }
- aria-label={t('printerAccess.removeGroup', { group: g.name })}
- className="p-0.5 rounded-full hover:bg-bambu-green/30"
- >
- <X className="w-3 h-3" />
- </button>
- </span>
- )
- )}
- {reaching.length === 0 && limitedGroups.length > 0 && (
- <span className="text-xs text-bambu-gray">{t('printerAccess.noLimitedGroupReaches')}</span>
- )}
- </div>
- {addable.length > 0 && (
- <select
- value=""
- onChange={(e) => {
- const g = limitedGroups.find((x) => x.id === Number(e.target.value));
- if (g) updateDraft(g, (d) => ({ ...d, printerIds: [...d.printerIds, p.id] }));
- }}
- aria-label={t('printerAccess.addGroup')}
- className={`${inputClass} md:w-48 shrink-0`}
- >
- <option value="">{t('printerAccess.addGroup')}</option>
- {addable.map((g) => (
- <option key={g.id} value={g.id}>
- {g.name}
- </option>
- ))}
- </select>
- )}
- </div>
- );
- })}
- </div>
- </Card>
- )}
- </div>
- );
- };
- return (
- <div className="space-y-4 max-w-6xl" id="card-printer-access">
- <p className="text-sm text-bambu-gray">{t('printerAccess.intro')}</p>
- <div className="flex gap-1 p-1 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary w-fit">
- {(['groups', 'printers'] as const).map((v) => (
- <button
- key={v}
- type="button"
- onClick={() => setParams({ view: v === 'groups' ? null : 'printers' })}
- className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
- view === v ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'
- }`}
- >
- {v === 'groups' ? t('printerAccess.byGroup') : t('printerAccess.byPrinter')}
- </button>
- ))}
- </div>
- {view === 'groups' ? (
- <div className="flex flex-col lg:flex-row gap-4 items-start">
- {renderGroupList()}
- {renderGroupEditor()}
- </div>
- ) : (
- renderByPrinter()
- )}
- {isDirty && (
- <div className="sticky bottom-0 z-20 flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-xl bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-lg">
- <span className="text-sm text-white">
- {t('printerAccess.changedGroups', { names: dirtyGroups.map((g) => g.name).join(', ') })}
- </span>
- <div className="flex items-center gap-2">
- <Button variant="secondary" onClick={() => setDrafts({})} disabled={saveMutation.isPending}>
- {t('printerAccess.discard')}
- </Button>
- <Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
- {saveMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- {t('common.save')}
- </Button>
- </div>
- </div>
- )}
- </div>
- );
- }
|