PrinterLocationsPage.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Link } from 'react-router-dom';
  5. import { ArrowLeft, Box, CheckSquare, ChevronDown, Loader2, Move, Pencil, Plus, Search, Square, Trash2, UserMinus, X } from 'lucide-react';
  6. import { api } from '../api/client';
  7. import type { Printer, PrinterLocation } from '../api/client';
  8. import { Button } from '../components/Button';
  9. import { Card, CardContent } from '../components/Card';
  10. import { ConfirmModal } from '../components/ConfirmModal';
  11. import { IconPicker, AVAILABLE_ICONS } from '../components/IconPicker';
  12. import { useAuth } from '../contexts/AuthContext';
  13. import { useToast } from '../contexts/ToastContext';
  14. // Matches printers.location / printer_locations.name (VARCHAR(100)).
  15. const LOCATION_NAME_MAX_LENGTH = 100;
  16. const LOCATION_COLORS = [
  17. '#ef4444', // red
  18. '#f97316', // orange
  19. '#eab308', // yellow
  20. '#22c55e', // green
  21. '#14b8a6', // teal
  22. '#3b82f6', // blue
  23. '#8b5cf6', // violet
  24. '#ec4899', // pink
  25. '#6b7280', // gray
  26. ];
  27. type SortMode = 'name-asc' | 'name-desc' | 'count-asc' | 'count-desc';
  28. const SORT_MODES: SortMode[] = ['name-asc', 'name-desc', 'count-asc', 'count-desc'];
  29. // Per-viewer conveniences only; the locations themselves live on the server.
  30. const HIDE_EMPTY_KEY = 'printerLocations.hideEmpty';
  31. const SORT_KEY = 'printerLocations.sort';
  32. function readStored<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
  33. try {
  34. const value = localStorage.getItem(key);
  35. return allowed.includes(value as T) ? (value as T) : fallback;
  36. } catch {
  37. return fallback;
  38. }
  39. }
  40. function writeStored(key: string, value: string) {
  41. try {
  42. localStorage.setItem(key, value);
  43. } catch {
  44. // Private mode or storage disabled: the setting just is not remembered.
  45. }
  46. }
  47. const locationOf = (printer: Printer) => printer.location?.trim() || '';
  48. function LocationIcon({ location }: { location: Pick<PrinterLocation, 'icon' | 'color'> }) {
  49. const Icon = AVAILABLE_ICONS.find((i) => i.name === location.icon)?.icon ?? Box;
  50. return (
  51. <div className="p-2 rounded-lg bg-bambu-dark relative overflow-hidden flex-shrink-0">
  52. <Icon className="w-[25px] h-[25px] text-bambu-gray" />
  53. {location.color && (
  54. <div className="absolute top-0 left-0 w-1 h-full" style={{ backgroundColor: location.color }} />
  55. )}
  56. </div>
  57. );
  58. }
  59. interface PrinterRowProps {
  60. printer: Printer;
  61. status?: { connected?: boolean; state?: string | null };
  62. selected: boolean;
  63. canEdit: boolean;
  64. busy: boolean;
  65. onToggle: () => void;
  66. onMove: () => void;
  67. onRemove?: () => void;
  68. }
  69. function PrinterRow({ printer, status, selected, canEdit, busy, onToggle, onMove, onRemove }: PrinterRowProps) {
  70. const { t } = useTranslation();
  71. const state = status?.state;
  72. const connected = status?.connected;
  73. const label = !connected
  74. ? t('printers.status.offline')
  75. : state === 'RUNNING'
  76. ? t('printers.status.printing')
  77. : state === 'PAUSE'
  78. ? t('printers.status.paused')
  79. : state === 'FINISH'
  80. ? t('printers.status.finished')
  81. : t('printers.status.idle');
  82. const badge = !connected
  83. ? 'bg-gray-500/20 text-gray-400'
  84. : state === 'RUNNING'
  85. ? 'bg-orange-500/20 text-orange-400'
  86. : state === 'PAUSE'
  87. ? 'bg-yellow-500/20 text-yellow-400'
  88. : state === 'FINISH'
  89. ? 'bg-bambu-green/20 text-bambu-green'
  90. : 'bg-bambu-dark text-bambu-gray';
  91. return (
  92. <div
  93. className={`flex items-center justify-between gap-2 py-2 px-3 rounded-lg transition-colors ${
  94. selected ? 'bg-bambu-green/10 border border-bambu-green/30' : 'bg-bambu-dark-secondary hover:bg-bambu-dark'
  95. }`}
  96. >
  97. <div className="flex items-center gap-3 min-w-0">
  98. {canEdit && (
  99. <button
  100. type="button"
  101. onClick={onToggle}
  102. className="text-bambu-gray hover:text-bambu-green transition-colors"
  103. title={t('printers.locations.selectPrinter')}
  104. aria-label={t('printers.locations.selectPrinter')}
  105. aria-pressed={selected}
  106. >
  107. {selected ? <CheckSquare className="w-4 h-4 text-bambu-green" /> : <Square className="w-4 h-4" />}
  108. </button>
  109. )}
  110. <div
  111. className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
  112. connected ? (state === 'RUNNING' || state === 'PAUSE' ? 'bg-orange-500' : 'bg-bambu-green') : 'bg-gray-500'
  113. }`}
  114. />
  115. <div className="min-w-0">
  116. <p className="text-white text-sm font-medium truncate">{printer.name}</p>
  117. <p className="text-xs text-bambu-gray">{printer.model || t('printers.status.unknown')}</p>
  118. </div>
  119. </div>
  120. <div className="flex items-center gap-2 flex-shrink-0">
  121. <span className={`text-xs px-2 py-1 rounded-full ${badge}`}>{label}</span>
  122. {canEdit && (
  123. <Button
  124. variant="ghost"
  125. size="sm"
  126. onClick={onMove}
  127. disabled={busy}
  128. className="text-bambu-gray hover:text-bambu-green hover:bg-bambu-green/10"
  129. title={t('printers.locations.move')}
  130. aria-label={t('printers.locations.move')}
  131. >
  132. <Move className="w-3.5 h-3.5" />
  133. </Button>
  134. )}
  135. {canEdit && onRemove && (
  136. <Button
  137. variant="ghost"
  138. size="sm"
  139. onClick={onRemove}
  140. disabled={busy}
  141. className="text-bambu-gray hover:text-red-400 hover:bg-red-500/10"
  142. title={t('printers.locations.removeFromLocation')}
  143. aria-label={t('printers.locations.removeFromLocation')}
  144. >
  145. <UserMinus className="w-3.5 h-3.5" />
  146. </Button>
  147. )}
  148. </div>
  149. </div>
  150. );
  151. }
  152. interface LocationDialogProps {
  153. initial?: PrinterLocation;
  154. saving: boolean;
  155. onSave: (value: { name: string; icon: string; color: string }) => void;
  156. onCancel: () => void;
  157. }
  158. function LocationDialog({ initial, saving, onSave, onCancel }: LocationDialogProps) {
  159. const { t } = useTranslation();
  160. const [name, setName] = useState(initial?.name ?? '');
  161. const [icon, setIcon] = useState(initial?.icon ?? '');
  162. const [color, setColor] = useState(initial?.color ?? '');
  163. const canSave = name.trim().length > 0 && !saving;
  164. const save = () => canSave && onSave({ name: name.trim(), icon, color });
  165. return (
  166. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  167. <Card className="w-full max-w-md">
  168. <CardContent className="space-y-4">
  169. <h2 className="text-lg font-semibold text-white">
  170. {initial ? t('printers.locations.editTitle') : t('printers.locations.createTitle')}
  171. </h2>
  172. <input
  173. type="text"
  174. value={name}
  175. maxLength={LOCATION_NAME_MAX_LENGTH}
  176. onChange={(e) => setName(e.target.value)}
  177. placeholder={t('printers.locations.namePlaceholder')}
  178. aria-label={t('printers.locations.namePlaceholder')}
  179. 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"
  180. autoFocus
  181. onKeyDown={(e) => {
  182. if (e.key === 'Enter') save();
  183. if (e.key === 'Escape') onCancel();
  184. }}
  185. />
  186. <div>
  187. <p className="text-sm text-bambu-gray mb-2">{t('printers.locations.icon')}</p>
  188. <IconPicker value={icon} onChange={setIcon} />
  189. </div>
  190. <div>
  191. <p className="text-sm text-bambu-gray mb-2">{t('printers.locations.color')}</p>
  192. <div className="flex gap-2 flex-wrap">
  193. {LOCATION_COLORS.map((c) => (
  194. <button
  195. key={c}
  196. type="button"
  197. onClick={() => setColor(color === c ? '' : c)}
  198. className={`w-8 h-8 rounded-lg transition-all ${
  199. color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : 'hover:scale-105'
  200. }`}
  201. style={{ backgroundColor: c }}
  202. title={c}
  203. aria-label={c}
  204. aria-pressed={color === c}
  205. />
  206. ))}
  207. {color && (
  208. <button
  209. type="button"
  210. onClick={() => setColor('')}
  211. className="w-8 h-8 rounded-lg border-2 border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-gray hover:text-white hover:border-bambu-gray transition-all flex items-center justify-center"
  212. title={t('printers.locations.noColor')}
  213. aria-label={t('printers.locations.noColor')}
  214. >
  215. <X className="w-3.5 h-3.5" />
  216. </button>
  217. )}
  218. </div>
  219. </div>
  220. <div className="flex gap-2 justify-end">
  221. <Button variant="secondary" onClick={onCancel} disabled={saving}>
  222. {t('common.cancel')}
  223. </Button>
  224. <Button onClick={save} disabled={!canSave}>
  225. {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : initial ? t('common.save') : t('printers.locations.create')}
  226. </Button>
  227. </div>
  228. </CardContent>
  229. </Card>
  230. </div>
  231. );
  232. }
  233. interface MoveDialogProps {
  234. count: number;
  235. printerName?: string;
  236. locations: PrinterLocation[];
  237. saving: boolean;
  238. onMove: (location: string | null) => void;
  239. onCancel: () => void;
  240. }
  241. const NO_LOCATION = '__none__';
  242. function MoveDialog({ count, printerName, locations, saving, onMove, onCancel }: MoveDialogProps) {
  243. const { t } = useTranslation();
  244. const [target, setTarget] = useState('');
  245. return (
  246. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  247. <Card className="w-full max-w-md">
  248. <CardContent className="space-y-4">
  249. <h2 className="text-lg font-semibold text-white">
  250. {count === 1 ? t('printers.locations.moveTitle') : t('printers.locations.moveTitleMany', { count })}
  251. </h2>
  252. {printerName && <p className="text-sm text-bambu-gray">{printerName}</p>}
  253. <select
  254. value={target}
  255. onChange={(e) => setTarget(e.target.value)}
  256. aria-label={t('printers.locations.targetPlaceholder')}
  257. className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  258. disabled={saving}
  259. autoFocus
  260. >
  261. <option value="" disabled>
  262. {t('printers.locations.targetPlaceholder')}
  263. </option>
  264. {locations.map((loc) => (
  265. <option key={loc.name} value={loc.name}>
  266. {loc.name}
  267. </option>
  268. ))}
  269. <option value={NO_LOCATION}>{t('printers.locations.noLocation')}</option>
  270. </select>
  271. <div className="flex gap-2 justify-end">
  272. <Button variant="secondary" onClick={onCancel} disabled={saving}>
  273. {t('common.cancel')}
  274. </Button>
  275. <Button onClick={() => onMove(target === NO_LOCATION ? null : target)} disabled={saving || !target}>
  276. {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('printers.locations.moveButton')}
  277. </Button>
  278. </div>
  279. </CardContent>
  280. </Card>
  281. </div>
  282. );
  283. }
  284. export function PrinterLocationsPage() {
  285. const queryClient = useQueryClient();
  286. const { t } = useTranslation();
  287. const { showToast } = useToast();
  288. const { hasPermission } = useAuth();
  289. const canEdit = hasPermission('printers:update');
  290. const [search, setSearch] = useState('');
  291. const [hideEmpty, setHideEmpty] = useState(() => readStored(HIDE_EMPTY_KEY, ['true', 'false'], 'false') === 'true');
  292. const [sortMode, setSortMode] = useState<SortMode>(() => readStored(SORT_KEY, SORT_MODES, 'name-asc'));
  293. const [showSortMenu, setShowSortMenu] = useState(false);
  294. const [expanded, setExpanded] = useState<string | null>(null);
  295. const [selectingLocations, setSelectingLocations] = useState(false);
  296. const [selectedLocations, setSelectedLocations] = useState<Set<string>>(new Set());
  297. const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
  298. const [dialog, setDialog] = useState<{ mode: 'create' } | { mode: 'edit'; location: PrinterLocation } | null>(null);
  299. const [moving, setMoving] = useState<{ ids: number[]; printerName?: string } | null>(null);
  300. const [deleting, setDeleting] = useState<string[] | null>(null);
  301. useEffect(() => writeStored(HIDE_EMPTY_KEY, String(hideEmpty)), [hideEmpty]);
  302. useEffect(() => writeStored(SORT_KEY, sortMode), [sortMode]);
  303. useEffect(() => {
  304. if (selectingLocations) setExpanded(null);
  305. }, [selectingLocations]);
  306. const { data: printers, isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
  307. const { data: locations, isLoading: locationsLoading } = useQuery({
  308. queryKey: ['printer-locations'],
  309. queryFn: api.getPrinterLocations,
  310. });
  311. // Same query the printer cards use, so the cache and the WebSocket updates
  312. // are shared rather than polled twice.
  313. const statusQueries = useQueries({
  314. queries: (printers ?? []).map((p) => ({
  315. queryKey: ['printerStatus', p.id],
  316. queryFn: () => api.getPrinterStatus(p.id),
  317. refetchInterval: 30000,
  318. })),
  319. });
  320. const statusById = new Map((printers ?? []).map((p, i) => [p.id, statusQueries[i]?.data]));
  321. const printersByLocation = useMemo(() => {
  322. const map = new Map<string, Printer[]>();
  323. for (const p of printers ?? []) {
  324. const key = locationOf(p);
  325. map.set(key, [...(map.get(key) ?? []), p]);
  326. }
  327. return map;
  328. }, [printers]);
  329. const ungrouped = printersByLocation.get('') ?? [];
  330. const groupedCount = (printers?.length ?? 0) - ungrouped.length;
  331. const displayed = useMemo(() => {
  332. const q = search.trim().toLowerCase();
  333. const list = (locations ?? []).filter(
  334. (loc) => (!q || loc.name.toLowerCase().includes(q)) && (!hideEmpty || loc.printer_count > 0),
  335. );
  336. const byName = (a: PrinterLocation, b: PrinterLocation) =>
  337. a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
  338. return [...list].sort((a, b) => {
  339. switch (sortMode) {
  340. case 'name-desc':
  341. return byName(b, a);
  342. case 'count-asc':
  343. return a.printer_count - b.printer_count || byName(a, b);
  344. case 'count-desc':
  345. return b.printer_count - a.printer_count || byName(a, b);
  346. default:
  347. return byName(a, b);
  348. }
  349. });
  350. }, [locations, search, hideEmpty, sortMode]);
  351. const refresh = () => {
  352. queryClient.invalidateQueries({ queryKey: ['printer-locations'] });
  353. queryClient.invalidateQueries({ queryKey: ['printers'] });
  354. };
  355. const fail = (error: unknown) =>
  356. showToast(t('printers.locations.failed', { error: error instanceof Error ? error.message : String(error) }), 'error');
  357. const saveMutation = useMutation({
  358. mutationFn: ({ value, location }: { value: { name: string; icon: string; color: string }; location?: PrinterLocation }) =>
  359. location
  360. ? api.updatePrinterLocation({
  361. name: location.name,
  362. ...(value.name !== location.name ? { new_name: value.name } : {}),
  363. icon: value.icon || null,
  364. color: value.color || null,
  365. })
  366. : api.createPrinterLocation({ name: value.name, icon: value.icon || null, color: value.color || null }),
  367. onSuccess: (saved, { location }) => {
  368. refresh();
  369. if (location && expanded === location.name) setExpanded(saved.name);
  370. showToast(location ? t('printers.locations.saved') : t('printers.locations.created'));
  371. setDialog(null);
  372. },
  373. onError: fail,
  374. });
  375. const moveMutation = useMutation({
  376. mutationFn: ({ ids, location }: { ids: number[]; location: string | null }) => api.assignPrinterLocation(ids, location),
  377. onSuccess: ({ moved }) => {
  378. refresh();
  379. showToast(t('printers.locations.moved', { count: moved }));
  380. setSelectedPrinters(new Set());
  381. setMoving(null);
  382. },
  383. onError: fail,
  384. });
  385. const deleteMutation = useMutation({
  386. mutationFn: (names: string[]) => api.deletePrinterLocations(names),
  387. onSuccess: ({ deleted }) => {
  388. refresh();
  389. showToast(t('printers.locations.deleted', { count: deleted }));
  390. setSelectedLocations(new Set());
  391. setSelectingLocations(false);
  392. setDeleting(null);
  393. },
  394. onError: (error) => {
  395. fail(error);
  396. setDeleting(null);
  397. },
  398. });
  399. const busy = moveMutation.isPending || deleteMutation.isPending || saveMutation.isPending;
  400. const toggle = <T,>(set: Set<T>, value: T) => {
  401. const next = new Set(set);
  402. if (next.has(value)) next.delete(value);
  403. else next.add(value);
  404. return next;
  405. };
  406. if (printersLoading || locationsLoading) {
  407. return (
  408. <div className="flex items-center justify-center h-64">
  409. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  410. </div>
  411. );
  412. }
  413. const deletingPrinterCount = (deleting ?? []).reduce(
  414. (sum, name) => sum + (locations?.find((l) => l.name === name)?.printer_count ?? 0),
  415. 0,
  416. );
  417. const sortLabel = (mode: SortMode) =>
  418. ({
  419. 'name-asc': t('printers.locations.sortNameAsc'),
  420. 'name-desc': t('printers.locations.sortNameDesc'),
  421. 'count-asc': t('printers.locations.sortCountAsc'),
  422. 'count-desc': t('printers.locations.sortCountDesc'),
  423. })[mode];
  424. const renderPrinter = (printer: Printer, removable: boolean) => (
  425. <PrinterRow
  426. key={printer.id}
  427. printer={printer}
  428. status={statusById.get(printer.id)}
  429. selected={selectedPrinters.has(printer.id)}
  430. canEdit={canEdit && !selectingLocations}
  431. busy={busy}
  432. onToggle={() => setSelectedPrinters(toggle(selectedPrinters, printer.id))}
  433. onMove={() => setMoving({ ids: [printer.id], printerName: printer.name })}
  434. onRemove={removable ? () => moveMutation.mutate({ ids: [printer.id], location: null }) : undefined}
  435. />
  436. );
  437. const allUngroupedSelected = ungrouped.length > 0 && ungrouped.every((p) => selectedPrinters.has(p.id));
  438. return (
  439. <div className="p-4 md:p-8 pb-28">
  440. <Link
  441. to="/"
  442. className="inline-flex items-center gap-1 mb-3 text-sm text-bambu-gray hover:text-white transition-colors"
  443. >
  444. <ArrowLeft className="w-4 h-4" /> {t('printers.locations.backToPrinters')}
  445. </Link>
  446. <div className="mb-4">
  447. <div className="flex items-center gap-3 mb-1">
  448. <Box className="w-[25px] h-[25px] text-bambu-green" />
  449. <h1 className="text-2xl font-bold text-white">{t('printers.locations.title')}</h1>
  450. </div>
  451. <p className="text-sm text-bambu-gray">
  452. {t('printers.locations.subtitle', { grouped: groupedCount, ungrouped: ungrouped.length })}
  453. </p>
  454. </div>
  455. <div className="flex flex-wrap gap-3 mb-6">
  456. <div className="relative flex-1 min-w-[12rem]">
  457. <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
  458. <input
  459. type="text"
  460. value={search}
  461. onChange={(e) => setSearch(e.target.value)}
  462. placeholder={t('printers.locations.search')}
  463. aria-label={t('printers.locations.search')}
  464. 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"
  465. />
  466. </div>
  467. <Button onClick={() => setHideEmpty(!hideEmpty)} variant={hideEmpty ? 'primary' : 'secondary'}>
  468. {hideEmpty ? t('printers.locations.showEmpty') : t('printers.locations.hideEmpty')}
  469. </Button>
  470. {canEdit && (
  471. <Button
  472. onClick={() => {
  473. setSelectedLocations(new Set());
  474. setSelectingLocations(!selectingLocations);
  475. }}
  476. variant={selectingLocations ? 'primary' : 'secondary'}
  477. >
  478. <CheckSquare className="w-4 h-4 mr-1" />
  479. {selectingLocations ? t('printers.locations.done') : t('printers.locations.select')}
  480. </Button>
  481. )}
  482. <div className="relative">
  483. <Button onClick={() => setShowSortMenu(!showSortMenu)} variant="secondary" aria-haspopup="menu">
  484. <ChevronDown className={`w-4 h-4 mr-1 transition-transform ${showSortMenu ? 'rotate-180' : ''}`} />
  485. {sortLabel(sortMode)}
  486. </Button>
  487. {showSortMenu && (
  488. <>
  489. <div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
  490. <div role="menu" className="absolute right-0 top-full mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-1 min-w-[10rem]">
  491. {SORT_MODES.map((mode) => (
  492. <button
  493. key={mode}
  494. role="menuitem"
  495. onClick={() => {
  496. setSortMode(mode);
  497. setShowSortMenu(false);
  498. }}
  499. className={`w-full text-left px-3 py-2 text-sm rounded transition-colors ${
  500. sortMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'
  501. }`}
  502. >
  503. {sortLabel(mode)}
  504. </button>
  505. ))}
  506. </div>
  507. </>
  508. )}
  509. </div>
  510. {canEdit && (
  511. <Button onClick={() => setDialog({ mode: 'create' })}>
  512. <Plus className="w-4 h-4 mr-1" />
  513. {t('printers.locations.create')}
  514. </Button>
  515. )}
  516. </div>
  517. {displayed.length === 0 && ungrouped.length === 0 ? (
  518. <Card>
  519. <CardContent className="text-center py-12 text-bambu-gray">
  520. {search ? t('printers.locations.noResults') : t('printers.locations.none')}
  521. </CardContent>
  522. </Card>
  523. ) : (
  524. <div className="space-y-3">
  525. {displayed.map((loc) => {
  526. const isExpanded = expanded === loc.name;
  527. const isSelected = selectedLocations.has(loc.name);
  528. return (
  529. <Card key={loc.name}>
  530. <CardContent className="p-4">
  531. <div className="flex items-center justify-between gap-2">
  532. <button
  533. type="button"
  534. onClick={() =>
  535. selectingLocations
  536. ? setSelectedLocations(toggle(selectedLocations, loc.name))
  537. : setExpanded(isExpanded ? null : loc.name)
  538. }
  539. className="flex items-center gap-3 flex-1 min-w-0 text-left"
  540. aria-expanded={selectingLocations ? undefined : isExpanded}
  541. aria-pressed={selectingLocations ? isSelected : undefined}
  542. >
  543. {selectingLocations ? (
  544. isSelected ? (
  545. <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
  546. ) : (
  547. <Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  548. )
  549. ) : (
  550. <ChevronDown
  551. className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
  552. />
  553. )}
  554. <LocationIcon location={loc} />
  555. <div className="min-w-0">
  556. <p className="text-white font-medium truncate">{loc.name}</p>
  557. <p className="text-sm text-bambu-gray">{t('printers.locations.printerCount', { count: loc.printer_count })}</p>
  558. </div>
  559. </button>
  560. {canEdit && !selectingLocations && (
  561. <div className="flex items-center gap-2">
  562. <Button
  563. variant="ghost"
  564. size="sm"
  565. onClick={() => setDialog({ mode: 'edit', location: loc })}
  566. disabled={busy}
  567. className="text-bambu-gray hover:text-blue-400 hover:bg-blue-500/10"
  568. title={t('common.edit')}
  569. aria-label={t('common.edit')}
  570. >
  571. <Pencil className="w-4 h-4" />
  572. </Button>
  573. <Button
  574. variant="ghost"
  575. size="sm"
  576. onClick={() => setDeleting([loc.name])}
  577. disabled={busy}
  578. className="text-red-500 hover:text-red-400 hover:bg-red-500/10"
  579. title={t('common.delete')}
  580. aria-label={t('common.delete')}
  581. >
  582. <Trash2 className="w-4 h-4" />
  583. </Button>
  584. </div>
  585. )}
  586. </div>
  587. {isExpanded && (
  588. <div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-2">
  589. {(printersByLocation.get(loc.name) ?? []).length === 0 ? (
  590. <p className="text-sm text-bambu-gray text-center py-4">{t('printers.locations.noPrinters')}</p>
  591. ) : (
  592. (printersByLocation.get(loc.name) ?? []).map((p) => renderPrinter(p, true))
  593. )}
  594. </div>
  595. )}
  596. </CardContent>
  597. </Card>
  598. );
  599. })}
  600. {ungrouped.length > 0 && (
  601. <div className="pt-4 border-t border-bambu-dark-tertiary">
  602. <div className="flex items-center justify-between mb-3">
  603. <h3 className="text-sm font-medium text-bambu-gray">
  604. {t('printers.locations.ungroupedPrinters', { count: ungrouped.length })}
  605. </h3>
  606. {canEdit && !selectingLocations && (
  607. <button
  608. type="button"
  609. onClick={() => {
  610. const next = new Set(selectedPrinters);
  611. ungrouped.forEach((p) => (allUngroupedSelected ? next.delete(p.id) : next.add(p.id)));
  612. setSelectedPrinters(next);
  613. }}
  614. className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors"
  615. >
  616. {allUngroupedSelected ? t('common.deselectAll') : t('common.selectAll')}
  617. </button>
  618. )}
  619. </div>
  620. <div className="space-y-2">{ungrouped.map((p) => renderPrinter(p, false))}</div>
  621. </div>
  622. )}
  623. </div>
  624. )}
  625. {canEdit && !selectingLocations && selectedPrinters.size > 0 && (
  626. <div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
  627. <span className="text-white text-sm">{t('printers.locations.selected', { count: selectedPrinters.size })}</span>
  628. <Button onClick={() => setMoving({ ids: Array.from(selectedPrinters) })} disabled={busy}>
  629. <Move className="w-4 h-4 mr-1" />
  630. {t('printers.locations.moveButton')}
  631. </Button>
  632. <Button variant="ghost" onClick={() => setSelectedPrinters(new Set())} disabled={busy}>
  633. {t('common.cancel')}
  634. </Button>
  635. </div>
  636. )}
  637. {selectingLocations && selectedLocations.size > 0 && (
  638. <div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
  639. <span className="text-white text-sm">{t('printers.locations.selected', { count: selectedLocations.size })}</span>
  640. <Button variant="danger" onClick={() => setDeleting(Array.from(selectedLocations))} disabled={busy}>
  641. <Trash2 className="w-4 h-4 mr-1" />
  642. {t('printers.locations.deleteSelected')}
  643. </Button>
  644. <Button variant="ghost" onClick={() => setSelectedLocations(new Set())} disabled={busy}>
  645. {t('common.cancel')}
  646. </Button>
  647. </div>
  648. )}
  649. {dialog && (
  650. <LocationDialog
  651. initial={dialog.mode === 'edit' ? dialog.location : undefined}
  652. saving={saveMutation.isPending}
  653. onSave={(value) => saveMutation.mutate({ value, location: dialog.mode === 'edit' ? dialog.location : undefined })}
  654. onCancel={() => setDialog(null)}
  655. />
  656. )}
  657. {moving && (
  658. <MoveDialog
  659. count={moving.ids.length}
  660. printerName={moving.printerName}
  661. locations={locations ?? []}
  662. saving={moveMutation.isPending}
  663. onMove={(location) => moveMutation.mutate({ ids: moving.ids, location })}
  664. onCancel={() => setMoving(null)}
  665. />
  666. )}
  667. {deleting && (
  668. <ConfirmModal
  669. title={
  670. deleting.length === 1
  671. ? t('printers.locations.deleteTitle')
  672. : t('printers.locations.deleteTitleMany', { count: deleting.length })
  673. }
  674. message={t('printers.locations.deleteMessage', {
  675. count: deletingPrinterCount,
  676. names: deleting.join(', '),
  677. })}
  678. confirmText={t('common.delete')}
  679. variant="danger"
  680. isLoading={deleteMutation.isPending}
  681. onConfirm={() => deleteMutation.mutate(deleting)}
  682. onCancel={() => setDeleting(null)}
  683. />
  684. )}
  685. </div>
  686. );
  687. }