SpoolBuddyDashboard.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. import { useState, useEffect, useMemo, useRef } from 'react';
  2. import { useOutletContext } from 'react-router-dom';
  3. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
  6. import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
  7. import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
  8. import { useToast } from '../../contexts/ToastContext';
  9. import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
  10. import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
  11. import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
  12. import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
  13. function normalizeHexTag(value: string | null | undefined): string {
  14. if (!value) return '';
  15. return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
  16. }
  17. function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
  18. const aNorm = normalizeHexTag(a);
  19. const bNorm = normalizeHexTag(b);
  20. if (!aNorm || !bNorm) return false;
  21. if (aNorm === bNorm) return true;
  22. // Some readers report shortened UID forms.
  23. return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
  24. }
  25. // Color palette for the cycling spool animation
  26. const SPOOL_COLORS = [
  27. '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
  28. '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
  29. ];
  30. // --- Idle state with slow color-cycling spool ---
  31. function IdleSpool() {
  32. const { t } = useTranslation();
  33. const [colorIndex, setColorIndex] = useState(0);
  34. useEffect(() => {
  35. const interval = setInterval(() => {
  36. setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length);
  37. }, 5000);
  38. return () => clearInterval(interval);
  39. }, []);
  40. const currentColor = SPOOL_COLORS[colorIndex];
  41. return (
  42. <div className="flex flex-col items-center text-center">
  43. {/* Animated spool with optimized NFC waves */}
  44. <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
  45. {/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
  46. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  47. {[0, 1].map((i) => (
  48. <div
  49. key={i}
  50. className="absolute rounded-full border spoolbuddy-optimized-ping"
  51. style={{
  52. width: 80,
  53. height: 80,
  54. borderColor: `${currentColor}4D`,
  55. transition: 'border-color 140ms linear',
  56. animationDelay: `${i * 0.8}s`,
  57. }}
  58. />
  59. ))}
  60. </div>
  61. {/* Spool icon with lightweight radial glow */}
  62. <div className="relative overflow-hidden rounded-full">
  63. <div
  64. className="absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow"
  65. style={{
  66. background: `radial-gradient(circle, ${currentColor} 0%, transparent 70%)`,
  67. }}
  68. />
  69. <div className="relative" style={{ transition: 'opacity 140ms linear' }}>
  70. <SpoolIcon color={currentColor} isEmpty={false} size={100} />
  71. </div>
  72. </div>
  73. </div>
  74. {/* Text content */}
  75. <div className="space-y-2">
  76. <p className="text-xl font-medium text-zinc-300">
  77. {t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}
  78. </p>
  79. <p className="text-sm text-zinc-500">
  80. {t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}
  81. </p>
  82. </div>
  83. {/* Subtle hint */}
  84. <div className="mt-6 flex items-center gap-2 text-sm text-zinc-600">
  85. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  86. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  87. </svg>
  88. <span>{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}</span>
  89. </div>
  90. </div>
  91. );
  92. }
  93. // --- Offline state ---
  94. function DeviceOfflineState() {
  95. const { t } = useTranslation();
  96. return (
  97. <div className="flex flex-col items-center text-center">
  98. {/* Offline icon */}
  99. <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
  100. <div className="w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center">
  101. <svg className="w-12 h-12 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  102. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
  103. </svg>
  104. </div>
  105. </div>
  106. <div className="space-y-2">
  107. <p className="text-lg font-medium text-zinc-500">
  108. {t('spoolbuddy.status.deviceOffline', 'Device Offline')}
  109. </p>
  110. <p className="text-sm text-zinc-600">
  111. {t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}
  112. </p>
  113. </div>
  114. <div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
  115. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  116. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
  117. </svg>
  118. <span>{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</span>
  119. </div>
  120. </div>
  121. );
  122. }
  123. // --- Main Dashboard ---
  124. export function SpoolBuddyDashboard() {
  125. const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
  126. const { t } = useTranslation();
  127. const { showToast } = useToast();
  128. const { data: spoolmanSettings } = useQuery({
  129. queryKey: ['spoolman-settings'],
  130. queryFn: api.getSpoolmanSettings,
  131. staleTime: 5 * 60 * 1000,
  132. });
  133. const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
  134. // Fetch spools for stats, tag lookup, and untagged list
  135. const { data: spools = [], refetch: refetchSpools } = useQuery({
  136. queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'],
  137. queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(false) : api.getSpools(false),
  138. enabled: spoolmanSettings !== undefined,
  139. });
  140. // Kiosk caveat: the SpoolBuddy display is a long-running browser window with
  141. // no focus/remount events, so a staleTime alone leaves this cache effectively
  142. // permanent. When slot assignments change from another client (Bambuddy main
  143. // UI, direct Spoolman edit, AssignToAmsModal on a separate browser), the
  144. // kiosk keeps showing the spool as still-assigned forever and isSpoolAssigned
  145. // reports stale, disabling the Assign button. Polling every 3 s is cheap
  146. // (slot-assignments/all is a tiny DB query) and bounds staleness to a window
  147. // operators don't notice.
  148. const { data: spoolmanSlotAssignments = [] } = useQuery({
  149. queryKey: ['spoolman-slot-assignments'],
  150. queryFn: () => api.getSpoolmanSlotAssignments(),
  151. enabled: spoolmanMode,
  152. staleTime: 3 * 1000,
  153. refetchInterval: 3 * 1000,
  154. refetchIntervalInBackground: false,
  155. });
  156. // Fetch printers and their statuses for the status badges
  157. const { data: printers = [] } = useQuery({
  158. queryKey: ['printers'],
  159. queryFn: () => api.getPrinters(),
  160. });
  161. const statusQueries = useQueries({
  162. queries: printers.map((printer: Printer) => ({
  163. queryKey: ['printerStatus', printer.id],
  164. queryFn: () => api.getPrinterStatus(printer.id),
  165. refetchInterval: 10000,
  166. select: (data: PrinterStatus) => ({
  167. connected: data?.connected,
  168. awaiting_plate_clear: data?.awaiting_plate_clear === true,
  169. }),
  170. })),
  171. });
  172. // Plate-clear: collect printers that are waiting for the operator to confirm.
  173. // The kiosk's API key passes the printers:clear_plate gate (not in the
  174. // _APIKEY_DENIED_PERMISSIONS set), so no extra perm wiring is needed here.
  175. const platesPending = printers
  176. .map((printer: Printer, i: number) => ({
  177. printer,
  178. pending: statusQueries[i]?.data?.awaiting_plate_clear === true,
  179. }))
  180. .filter((row: { pending: boolean }) => row.pending);
  181. const queryClient = useQueryClient();
  182. const clearPlateMutation = useMutation({
  183. mutationFn: (printerId: number) => api.clearPlate(printerId),
  184. onSuccess: (_data, printerId) => {
  185. // Optimistically clear the flag so the row vanishes immediately; the
  186. // backend already broadcasts a printer_status WS event after clearing,
  187. // but we don't want the user to see the row linger while that round-trips.
  188. queryClient.setQueryData(['printerStatus', printerId], (old: PrinterStatus | undefined) =>
  189. old ? { ...old, awaiting_plate_clear: false } : old
  190. );
  191. showToast(t('spoolbuddy.dashboard.plateClearedToast', 'Plate marked as cleared'), 'success');
  192. },
  193. onError: () => {
  194. showToast(t('spoolbuddy.dashboard.plateClearFailed', 'Could not mark plate as cleared'), 'error');
  195. },
  196. });
  197. const unassignSpoolMutation = useMutation({
  198. mutationFn: (spoolId: number) => api.unassignSpoolmanSlot(spoolId),
  199. onSuccess: () => {
  200. void queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  201. void queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  202. },
  203. onError: () => showToast(t('inventory.unassignFailed', 'Failed to unassign spool'), 'error'),
  204. });
  205. // Current Spool card state - persists until user closes or new tag detected
  206. const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
  207. const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
  208. const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
  209. const [showLinkModal, setShowLinkModal] = useState(false);
  210. const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
  211. const [showQuickAddModal, setShowQuickAddModal] = useState(false);
  212. const [quickAddBusy, setQuickAddBusy] = useState(false);
  213. const [justLinkedSpool, setJustLinkedSpool] = useState<Omit<MatchedSpool, 'tag_uid'> | null>(null);
  214. // Track current tag from state
  215. const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
  216. const currentWeight = sbState.weight;
  217. const weightStable = sbState.weightStable;
  218. // Stabilized scale display: only update when change exceeds threshold to prevent bouncing
  219. const stableDisplayWeight = useRef<number | null>(null);
  220. const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this
  221. if (currentWeight === null) {
  222. stableDisplayWeight.current = null;
  223. } else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {
  224. stableDisplayWeight.current = currentWeight;
  225. }
  226. const scaleDisplayValue = stableDisplayWeight.current;
  227. // Find spool by tag_id in the loaded spools list
  228. const displayedSpool = useMemo((): InventorySpool | null => {
  229. if (sbState.matchedSpool?.id) {
  230. const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
  231. if (byId) return byId;
  232. }
  233. if (!displayedTagId) return null;
  234. const byTag = spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId));
  235. if (byTag) return byTag;
  236. // When a Bambu tray UUID (32-char) is linked, Spoolman stores it in extra.tag and
  237. // _map_spoolman_spool routes it to tray_uuid, not tag_uid. tagsEquivalent only
  238. // compares tag_uid, so it misses this spool until the device re-scans and
  239. // sbState.matchedSpool is populated. Hold the spool returned by the link call
  240. // as a temporary bridge. Cast is safe: SpoolInfoCard only reads the 9 fields
  241. // present in MatchedSpool; AssignToAmsModal is guarded by !justLinkedSpool below.
  242. if (justLinkedSpool) return justLinkedSpool as unknown as InventorySpool;
  243. return null;
  244. }, [displayedTagId, sbState.matchedSpool, spools, justLinkedSpool]);
  245. // Effective spool for the Assign-to-AMS modal: prefer the fully-typed
  246. // InventorySpool from the local query cache, fall back to the
  247. // WebSocket-delivered MatchedSpool when the cached query hasn't caught up
  248. // (Spoolman spool added or unarchived after the dashboard loaded — the
  249. // initial fetch with includeArchived=false misses it). Without this
  250. // fallback the SpoolInfoCard renders via its own
  251. // `displayedSpool ?? sbState.matchedSpool` path while the modal's stricter
  252. // guard silently fails to mount, so the "Assign to AMS" button looks
  253. // clickable but does nothing on click. MatchedSpool is a 9-field subset
  254. // of InventorySpool — slicer_filament* are absent, which is acceptable:
  255. // the modal's mismatch check yields 'none' for profile (same as a manual
  256. // inventory spool without a preset), and the assign API only needs the
  257. // spool id to route to the correct row.
  258. const effectiveModalSpool: InventorySpool | null = useMemo(() => {
  259. if (displayedSpool && !justLinkedSpool) return displayedSpool;
  260. const m = sbState.matchedSpool;
  261. if (!m) return null;
  262. return {
  263. id: m.id,
  264. tag_uid: m.tag_uid,
  265. material: m.material,
  266. subtype: m.subtype,
  267. color_name: m.color_name,
  268. rgba: m.rgba,
  269. brand: m.brand,
  270. label_weight: m.label_weight,
  271. core_weight: m.core_weight,
  272. weight_used: m.weight_used,
  273. } as unknown as InventorySpool;
  274. }, [displayedSpool, justLinkedSpool, sbState.matchedSpool]);
  275. const isSpoolAssigned = spoolmanMode && effectiveModalSpool != null
  276. ? spoolmanSlotAssignments.some(a => a.spoolman_spool_id === effectiveModalSpool.id)
  277. : false;
  278. // Untagged spools for the Link feature
  279. const untaggedSpools = useMemo(() => {
  280. return spoolmanMode
  281. ? spools.filter((s) => !s.tag_uid && !s.tray_uuid && !s.archived_at)
  282. : spools.filter((s) => !s.tag_uid && !s.archived_at);
  283. }, [spools, spoolmanMode]);
  284. // Handle tag detection - show card when tag detected, keep until user closes or new tag
  285. useEffect(() => {
  286. if (currentTagId) {
  287. const isHidden = hiddenTagId === currentTagId;
  288. const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;
  289. if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
  290. setDisplayedTagId(currentTagId);
  291. setDisplayedWeight(null);
  292. setHiddenTagId(null);
  293. setJustLinkedSpool(null);
  294. }
  295. // Update weight when stable and card is visible
  296. if (!isHidden && currentWeight !== null && weightStable) {
  297. setDisplayedWeight(Math.round(Math.max(0, currentWeight)));
  298. }
  299. } else {
  300. // Tag removed - clear hidden state so same tag can show when re-placed
  301. if (hiddenTagId) {
  302. setDisplayedTagId(null);
  303. setHiddenTagId(null);
  304. setDisplayedWeight(null);
  305. setJustLinkedSpool(null);
  306. }
  307. }
  308. }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
  309. // Auto-sync weight once when known spool first detected
  310. const handleCloseSpoolCard = () => {
  311. setHiddenTagId(displayedTagId);
  312. };
  313. const handleLinkTagToSpool = async (spool: InventorySpool) => {
  314. if (!displayedTagId) return;
  315. try {
  316. if (spoolmanMode) {
  317. const tag_uid = sbState.unknownTagUid || undefined;
  318. const tray_uuid = (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined;
  319. if (!tag_uid && !tray_uuid) {
  320. showToast(t('spoolman.linkFailed'), 'error');
  321. return;
  322. }
  323. const raw = await api.linkTagToSpoolmanSpool(spool.id, { tray_uuid, tag_uid });
  324. const updated = raw as InventorySpool | undefined;
  325. if (!updated) {
  326. showToast(t('spoolman.linkFailed'), 'error');
  327. return;
  328. }
  329. const { id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used } = updated;
  330. setJustLinkedSpool({ id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used });
  331. showToast(t('spoolman.linkSuccess'), 'success');
  332. } else {
  333. await api.linkTagToSpool(spool.id, {
  334. tag_uid: displayedTagId,
  335. tag_type: 'generic',
  336. data_origin: 'nfc_link',
  337. });
  338. }
  339. refetchSpools();
  340. } catch (e) {
  341. console.error('Failed to link tag:', e);
  342. showToast(t('spoolman.linkFailed'), 'error');
  343. } finally {
  344. setShowLinkModal(false);
  345. }
  346. };
  347. const handleQuickAddToInventory = async () => {
  348. if (!displayedTagId) return;
  349. setQuickAddBusy(true);
  350. try {
  351. const weight = liveWeight ?? displayedWeight;
  352. if (spoolmanMode) {
  353. const created = await api.createSpoolmanInventorySpool({
  354. material: 'PLA',
  355. subtype: null,
  356. color_name: null,
  357. rgba: null,
  358. extra_colors: null,
  359. effect_type: null,
  360. brand: null,
  361. label_weight: 1000,
  362. core_weight: 250,
  363. core_weight_catalog_id: null,
  364. weight_used: 0,
  365. slicer_filament: null,
  366. slicer_filament_name: null,
  367. nozzle_temp_min: null,
  368. nozzle_temp_max: null,
  369. note: null,
  370. added_full: null,
  371. last_used: null,
  372. encode_time: null,
  373. tag_uid: null,
  374. tray_uuid: null,
  375. data_origin: null,
  376. tag_type: null,
  377. cost_per_kg: null,
  378. last_scale_weight: weight !== null ? Math.round(weight) : null,
  379. last_weighed_at: weight !== null ? new Date().toISOString() : null,
  380. category: null,
  381. low_stock_threshold_pct: null,
  382. });
  383. await api.linkTagToSpoolmanSpool(created.id, {
  384. tag_uid: sbState.unknownTagUid || undefined,
  385. tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined,
  386. });
  387. } else {
  388. await api.createSpool({
  389. material: 'PLA',
  390. subtype: null,
  391. color_name: null,
  392. rgba: null,
  393. extra_colors: null,
  394. effect_type: null,
  395. brand: null,
  396. label_weight: 1000,
  397. core_weight: 250,
  398. core_weight_catalog_id: null,
  399. weight_used: 0,
  400. slicer_filament: null,
  401. slicer_filament_name: null,
  402. nozzle_temp_min: null,
  403. nozzle_temp_max: null,
  404. note: null,
  405. added_full: null,
  406. last_used: null,
  407. encode_time: null,
  408. tag_uid: displayedTagId,
  409. tray_uuid: null,
  410. data_origin: 'spoolbuddy',
  411. tag_type: 'generic',
  412. cost_per_kg: null,
  413. last_scale_weight: weight !== null ? Math.round(weight) : null,
  414. last_weighed_at: weight !== null ? new Date().toISOString() : null,
  415. category: null,
  416. low_stock_threshold_pct: null,
  417. });
  418. }
  419. } catch (e) {
  420. const msg = e instanceof Error ? e.message : String(e);
  421. console.error('Failed to quick-add spool:', msg);
  422. showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');
  423. } finally {
  424. setShowQuickAddModal(false);
  425. setQuickAddBusy(false);
  426. refetchSpools();
  427. }
  428. };
  429. // For unknown tags, use live weight or stored displayed weight
  430. const useScaleWeight = currentWeight !== null &&
  431. (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
  432. const liveWeight = useScaleWeight ? currentWeight : null;
  433. // Stats
  434. const totalSpools = spools.length;
  435. const materials = new Set(spools.map((s) => s.material)).size;
  436. const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
  437. return (
  438. <div className="h-full flex flex-col p-4">
  439. {/* Compact stats bar */}
  440. <div className="flex items-center gap-6 px-4 py-1.5 bg-zinc-800/50 rounded-xl border border-zinc-700/50 mb-3 shrink-0">
  441. <div className="flex items-center gap-2">
  442. <span className="text-xl font-bold text-zinc-100">{totalSpools}</span>
  443. <span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
  444. </div>
  445. <div className="w-px h-5 bg-zinc-700" />
  446. <div className="flex items-center gap-2">
  447. <span className="text-xl font-bold text-zinc-100">{materials}</span>
  448. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.material', 'Materials')}</span>
  449. </div>
  450. <div className="w-px h-5 bg-zinc-700" />
  451. <div className="flex items-center gap-2">
  452. <span className="text-xl font-bold text-zinc-100">{brands}</span>
  453. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.brand', 'Brands')}</span>
  454. </div>
  455. </div>
  456. {/* Main content: Device (left) + Current Spool (right) */}
  457. <div className="flex-1 flex gap-4 min-h-0">
  458. {/* Left column */}
  459. <div className="w-5/12 flex flex-col min-h-0">
  460. {/* Device card */}
  461. <div className="border border-dashed border-zinc-700/50 rounded-xl p-4">
  462. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">
  463. {t('spoolbuddy.dashboard.device', 'Device')}
  464. </h2>
  465. <div className="space-y-2.5">
  466. {/* Connection status */}
  467. <div className="flex items-center gap-3">
  468. <div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-red-500'}`} />
  469. <span className="text-base text-zinc-400">
  470. {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
  471. </span>
  472. </div>
  473. {/* Scale weight */}
  474. <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
  475. <div className="flex items-center gap-2">
  476. <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  477. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
  478. </svg>
  479. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
  480. </div>
  481. <span className="text-lg font-mono font-semibold text-zinc-100">
  482. {scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
  483. </span>
  484. </div>
  485. {/* NFC status */}
  486. <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
  487. <div className="flex items-center gap-2">
  488. <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  489. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
  490. </svg>
  491. <span className="text-sm text-zinc-500">NFC</span>
  492. </div>
  493. <span className={`text-sm font-medium ${currentTagId ? 'text-green-500' : 'text-zinc-500'}`}>
  494. {currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
  495. </span>
  496. </div>
  497. </div>
  498. </div>
  499. {/* Printer status badges */}
  500. {printers.length > 0 && (
  501. <div className="mt-3 border border-dashed border-zinc-700/50 rounded-xl p-4">
  502. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2.5">
  503. {t('spoolbuddy.dashboard.printers', 'Printers')}
  504. </h2>
  505. <div className="flex flex-wrap gap-2 overflow-hidden">
  506. {printers.map((printer: Printer, i: number) => {
  507. const isOnline = statusQueries[i]?.data?.connected ?? false;
  508. return (
  509. <div
  510. key={printer.id}
  511. className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 rounded-lg"
  512. title={`${printer.name} — ${isOnline ? 'Online' : 'Offline'}`}
  513. >
  514. <div className={`w-2 h-2 rounded-full shrink-0 ${isOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
  515. <span className="text-xs text-zinc-400 truncate max-w-[100px]">{printer.name}</span>
  516. </div>
  517. );
  518. })}
  519. </div>
  520. {/* Plate-ready pills — same compact size as the printer badges above so
  521. the row stays scannable when multiple printers finish at once.
  522. Wraps via flex-wrap. Each pill is independently tappable. */}
  523. {platesPending.length > 0 && (
  524. <div
  525. className="mt-2 flex flex-wrap gap-2"
  526. data-testid="plate-clear-section"
  527. aria-label={t('spoolbuddy.dashboard.plateReadyLabel', 'Plates ready to clear')}
  528. >
  529. {platesPending.map(({ printer }: { printer: Printer }) => (
  530. <button
  531. key={printer.id}
  532. type="button"
  533. onClick={() => clearPlateMutation.mutate(printer.id)}
  534. disabled={clearPlateMutation.isPending}
  535. data-testid={`plate-clear-button-${printer.id}`}
  536. title={t('spoolbuddy.dashboard.plateReady', 'Plate ready: {{name}}', { name: printer.name })}
  537. className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 active:bg-amber-500/30 border border-amber-500/30 text-amber-200 transition-colors disabled:opacity-60 disabled:cursor-wait"
  538. >
  539. <svg className="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
  540. <path d="M12 9v4" />
  541. <path d="M12 17h.01" />
  542. <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
  543. </svg>
  544. <span className="text-xs truncate max-w-[100px]">{printer.name}</span>
  545. <span className="text-xs opacity-70" aria-hidden="true">·</span>
  546. <span className="text-xs font-medium">
  547. {t('spoolbuddy.dashboard.plateClearAction', 'Clear')}
  548. </span>
  549. </button>
  550. ))}
  551. </div>
  552. )}
  553. </div>
  554. )}
  555. </div>
  556. {/* Right column: Current Spool */}
  557. <div className="w-7/12 min-h-0">
  558. <div className="border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col">
  559. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0">
  560. {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
  561. </h2>
  562. <div className="flex-1 flex items-center justify-center min-h-0">
  563. {!sbState.deviceOnline ? (
  564. <DeviceOfflineState />
  565. ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
  566. <SpoolInfoCard
  567. spool={(() => {
  568. const s = displayedSpool ?? sbState.matchedSpool!;
  569. return {
  570. id: s.id,
  571. tag_uid: displayedTagId,
  572. material: s.material,
  573. subtype: s.subtype,
  574. color_name: s.color_name,
  575. rgba: s.rgba,
  576. brand: s.brand,
  577. label_weight: s.label_weight,
  578. core_weight: s.core_weight,
  579. weight_used: s.weight_used,
  580. };
  581. })()}
  582. scaleWeight={liveWeight ?? displayedWeight}
  583. onSyncWeight={() => refetchSpools()}
  584. onAssignToAms={() => setShowAssignAmsModal(true)}
  585. isAssigned={isSpoolAssigned}
  586. onUnassignFromAms={
  587. (isSpoolAssigned && displayedSpool?.id != null)
  588. ? () => unassignSpoolMutation.mutate(displayedSpool!.id)
  589. : undefined
  590. }
  591. onClose={handleCloseSpoolCard}
  592. />
  593. ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
  594. <UnknownTagCard
  595. tagUid={displayedTagId}
  596. scaleWeight={liveWeight ?? displayedWeight}
  597. onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
  598. onAddToInventory={() => setShowQuickAddModal(true)}
  599. onClose={handleCloseSpoolCard}
  600. />
  601. ) : (
  602. <IdleSpool />
  603. )}
  604. </div>
  605. </div>
  606. </div>
  607. </div>
  608. {/* Assign to AMS Modal — uses effectiveModalSpool which falls back to
  609. sbState.matchedSpool when the cached inventory query hasn't caught up
  610. to the matched spool (newly-added or unarchived in Spoolman). The
  611. !justLinkedSpool guard still excludes the freshly-linked synthetic
  612. spool because that path goes through a different flow. */}
  613. {effectiveModalSpool && !justLinkedSpool && displayedTagId && (
  614. <AssignToAmsModal
  615. isOpen={showAssignAmsModal}
  616. onClose={() => setShowAssignAmsModal(false)}
  617. spool={effectiveModalSpool}
  618. printerId={selectedPrinterId}
  619. spoolmanMode={spoolmanMode}
  620. />
  621. )}
  622. {/* Link Tag to Spool Modal */}
  623. {displayedTagId && (
  624. <LinkSpoolModal
  625. isOpen={showLinkModal}
  626. onClose={() => setShowLinkModal(false)}
  627. tagId={displayedTagId}
  628. untaggedSpools={untaggedSpools}
  629. onLink={handleLinkTagToSpool}
  630. />
  631. )}
  632. {/* Quick-add to Inventory Modal */}
  633. {showQuickAddModal && displayedTagId && (
  634. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
  635. <div className="bg-zinc-800 rounded-2xl p-6 mx-4 max-w-sm w-full border border-zinc-700">
  636. <h3 className="text-lg font-semibold text-zinc-100 mb-3">
  637. {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
  638. </h3>
  639. {/* Hint */}
  640. <div className="flex gap-2.5 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg mb-4">
  641. <svg className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  642. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  643. </svg>
  644. <p className="text-sm text-amber-200/80">
  645. {t('spoolbuddy.modal.quickAddHint', 'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use "Assign Spool" here to assign the NFC tag.')}
  646. </p>
  647. </div>
  648. <p className="text-sm text-zinc-400 mb-1">
  649. {t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}
  650. </p>
  651. <p className="text-xs text-zinc-500 font-mono mb-5">{displayedTagId}</p>
  652. <div className="flex gap-3">
  653. <button
  654. onClick={() => setShowQuickAddModal(false)}
  655. className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
  656. >
  657. {t('common.cancel', 'Cancel')}
  658. </button>
  659. <button
  660. onClick={handleQuickAddToInventory}
  661. disabled={quickAddBusy}
  662. className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors min-h-[44px]"
  663. >
  664. {quickAddBusy ? t('common.saving', 'Saving...') : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}
  665. </button>
  666. </div>
  667. </div>
  668. </div>
  669. )}
  670. </div>
  671. );
  672. }