SpoolBuddyDashboard.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import { useState, useEffect, useMemo, useRef } from 'react';
  2. import { useOutletContext } from 'react-router-dom';
  3. import { useQuery, useQueries } 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 { useToast } from '../../contexts/ToastContext';
  8. import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
  9. import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
  10. import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
  11. import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
  12. function normalizeHexTag(value: string | null | undefined): string {
  13. if (!value) return '';
  14. return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
  15. }
  16. function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
  17. const aNorm = normalizeHexTag(a);
  18. const bNorm = normalizeHexTag(b);
  19. if (!aNorm || !bNorm) return false;
  20. if (aNorm === bNorm) return true;
  21. // Some readers report shortened UID forms.
  22. return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
  23. }
  24. // Color palette for the cycling spool animation
  25. const SPOOL_COLORS = [
  26. '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
  27. '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
  28. ];
  29. // --- Idle state with slow color-cycling spool ---
  30. function IdleSpool() {
  31. const { t } = useTranslation();
  32. const [colorIndex, setColorIndex] = useState(0);
  33. useEffect(() => {
  34. const interval = setInterval(() => {
  35. setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length);
  36. }, 5000);
  37. return () => clearInterval(interval);
  38. }, []);
  39. const currentColor = SPOOL_COLORS[colorIndex];
  40. return (
  41. <div className="flex flex-col items-center text-center">
  42. {/* Spool with single subtle NFC ring */}
  43. <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
  44. {/* Static NFC wave rings */}
  45. <div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30" />
  46. <div className="absolute w-32 h-32 rounded-full border border-green-500/20" />
  47. <div className="absolute w-40 h-40 rounded-full border border-green-500/10" />
  48. {/* Spool icon with slow color transition */}
  49. <div className="relative transition-colors duration-[2000ms]">
  50. <SpoolIcon color={currentColor} isEmpty={false} size={100} />
  51. </div>
  52. </div>
  53. {/* Text content */}
  54. <div className="space-y-2">
  55. <p className="text-xl font-medium text-zinc-300">
  56. {t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}
  57. </p>
  58. <p className="text-sm text-zinc-500">
  59. {t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}
  60. </p>
  61. </div>
  62. {/* Subtle hint */}
  63. <div className="mt-6 flex items-center gap-2 text-sm text-zinc-600">
  64. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  65. <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" />
  66. </svg>
  67. <span>{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}</span>
  68. </div>
  69. </div>
  70. );
  71. }
  72. // --- Offline state ---
  73. function DeviceOfflineState() {
  74. const { t } = useTranslation();
  75. return (
  76. <div className="flex flex-col items-center text-center">
  77. {/* Offline icon */}
  78. <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
  79. <div className="w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center">
  80. <svg className="w-12 h-12 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  81. <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" />
  82. </svg>
  83. </div>
  84. </div>
  85. <div className="space-y-2">
  86. <p className="text-lg font-medium text-zinc-500">
  87. {t('spoolbuddy.status.deviceOffline', 'Device Offline')}
  88. </p>
  89. <p className="text-sm text-zinc-600">
  90. {t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}
  91. </p>
  92. </div>
  93. <div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
  94. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  95. <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" />
  96. </svg>
  97. <span>{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</span>
  98. </div>
  99. </div>
  100. );
  101. }
  102. // --- Main Dashboard ---
  103. export function SpoolBuddyDashboard() {
  104. const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
  105. const { t } = useTranslation();
  106. const { showToast } = useToast();
  107. // Fetch spools for stats, tag lookup, and untagged list
  108. const { data: spools = [], refetch: refetchSpools } = useQuery({
  109. queryKey: ['inventory-spools'],
  110. queryFn: () => api.getSpools(false),
  111. });
  112. // Fetch printers and their statuses for the status badges
  113. const { data: printers = [] } = useQuery({
  114. queryKey: ['printers'],
  115. queryFn: () => api.getPrinters(),
  116. });
  117. const statusQueries = useQueries({
  118. queries: printers.map((printer: Printer) => ({
  119. queryKey: ['printerStatus', printer.id],
  120. queryFn: () => api.getPrinterStatus(printer.id),
  121. refetchInterval: 10000,
  122. select: (data: PrinterStatus) => ({ connected: data?.connected }),
  123. })),
  124. });
  125. // Current Spool card state - persists until user closes or new tag detected
  126. const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
  127. const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
  128. const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
  129. const [showLinkModal, setShowLinkModal] = useState(false);
  130. const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
  131. const [showQuickAddModal, setShowQuickAddModal] = useState(false);
  132. const [quickAddBusy, setQuickAddBusy] = useState(false);
  133. // Track current tag from state
  134. const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
  135. const currentWeight = sbState.weight;
  136. const weightStable = sbState.weightStable;
  137. // Stabilized scale display: only update when change exceeds threshold to prevent bouncing
  138. const stableDisplayWeight = useRef<number | null>(null);
  139. const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this
  140. if (currentWeight === null) {
  141. stableDisplayWeight.current = null;
  142. } else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {
  143. stableDisplayWeight.current = currentWeight;
  144. }
  145. const scaleDisplayValue = stableDisplayWeight.current;
  146. // Find spool by tag_id in the loaded spools list
  147. const displayedSpool = useMemo(() => {
  148. if (sbState.matchedSpool?.id) {
  149. const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
  150. if (byId) return byId;
  151. }
  152. if (!displayedTagId) return null;
  153. return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;
  154. }, [displayedTagId, sbState.matchedSpool, spools]);
  155. // Untagged spools for the Link feature
  156. const untaggedSpools = useMemo(() => {
  157. return spools.filter((s) => !s.tag_uid && !s.archived_at);
  158. }, [spools]);
  159. // Handle tag detection - show card when tag detected, keep until user closes or new tag
  160. useEffect(() => {
  161. if (currentTagId) {
  162. const isHidden = hiddenTagId === currentTagId;
  163. const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;
  164. if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
  165. setDisplayedTagId(currentTagId);
  166. setDisplayedWeight(null);
  167. setHiddenTagId(null);
  168. }
  169. // Update weight when stable and card is visible
  170. if (!isHidden && currentWeight !== null && weightStable) {
  171. setDisplayedWeight(Math.round(Math.max(0, currentWeight)));
  172. }
  173. } else {
  174. // Tag removed - clear hidden state so same tag can show when re-placed
  175. if (hiddenTagId) {
  176. setDisplayedTagId(null);
  177. setHiddenTagId(null);
  178. setDisplayedWeight(null);
  179. }
  180. }
  181. }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
  182. // Auto-sync weight once when known spool first detected
  183. const handleCloseSpoolCard = () => {
  184. setHiddenTagId(displayedTagId);
  185. };
  186. const handleLinkTagToSpool = async (spool: InventorySpool) => {
  187. if (!displayedTagId) return;
  188. try {
  189. await api.linkTagToSpool(spool.id, {
  190. tag_uid: displayedTagId,
  191. tag_type: 'generic',
  192. data_origin: 'nfc_link',
  193. });
  194. setShowLinkModal(false);
  195. refetchSpools();
  196. } catch (e) {
  197. console.error('Failed to link tag:', e);
  198. }
  199. };
  200. const handleQuickAddToInventory = async () => {
  201. if (!displayedTagId) return;
  202. setQuickAddBusy(true);
  203. try {
  204. const weight = liveWeight ?? displayedWeight;
  205. await api.createSpool({
  206. material: 'PLA',
  207. subtype: null,
  208. color_name: null,
  209. rgba: null,
  210. brand: null,
  211. label_weight: 1000,
  212. core_weight: 250,
  213. core_weight_catalog_id: null,
  214. weight_used: 0,
  215. slicer_filament: null,
  216. slicer_filament_name: null,
  217. nozzle_temp_min: null,
  218. nozzle_temp_max: null,
  219. note: null,
  220. added_full: null,
  221. last_used: null,
  222. encode_time: null,
  223. tag_uid: displayedTagId,
  224. tray_uuid: null,
  225. data_origin: 'spoolbuddy',
  226. tag_type: 'generic',
  227. cost_per_kg: null,
  228. last_scale_weight: weight !== null ? Math.round(weight) : null,
  229. last_weighed_at: weight !== null ? new Date().toISOString() : null,
  230. });
  231. } catch (e) {
  232. const msg = e instanceof Error ? e.message : String(e);
  233. console.error('Failed to quick-add spool:', msg);
  234. showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');
  235. } finally {
  236. setShowQuickAddModal(false);
  237. setQuickAddBusy(false);
  238. refetchSpools();
  239. }
  240. };
  241. // For unknown tags, use live weight or stored displayed weight
  242. const useScaleWeight = currentWeight !== null &&
  243. (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
  244. const liveWeight = useScaleWeight ? currentWeight : null;
  245. // Stats
  246. const totalSpools = spools.length;
  247. const materials = new Set(spools.map((s) => s.material)).size;
  248. const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
  249. return (
  250. <div className="h-full flex flex-col p-4">
  251. {/* Compact stats bar */}
  252. <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">
  253. <div className="flex items-center gap-2">
  254. <span className="text-xl font-bold text-zinc-100">{totalSpools}</span>
  255. <span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
  256. </div>
  257. <div className="w-px h-5 bg-zinc-700" />
  258. <div className="flex items-center gap-2">
  259. <span className="text-xl font-bold text-zinc-100">{materials}</span>
  260. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.material', 'Materials')}</span>
  261. </div>
  262. <div className="w-px h-5 bg-zinc-700" />
  263. <div className="flex items-center gap-2">
  264. <span className="text-xl font-bold text-zinc-100">{brands}</span>
  265. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.brand', 'Brands')}</span>
  266. </div>
  267. </div>
  268. {/* Main content: Device (left) + Current Spool (right) */}
  269. <div className="flex-1 flex gap-4 min-h-0">
  270. {/* Left column */}
  271. <div className="w-5/12 flex flex-col min-h-0">
  272. {/* Device card */}
  273. <div className="border border-dashed border-zinc-700/50 rounded-xl p-4">
  274. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">
  275. {t('spoolbuddy.dashboard.device', 'Device')}
  276. </h2>
  277. <div className="space-y-2.5">
  278. {/* Connection status */}
  279. <div className="flex items-center gap-3">
  280. <div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-red-500'}`} />
  281. <span className="text-base text-zinc-400">
  282. {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
  283. </span>
  284. </div>
  285. {/* Scale weight */}
  286. <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
  287. <div className="flex items-center gap-2">
  288. <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  289. <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" />
  290. </svg>
  291. <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
  292. </div>
  293. <span className="text-lg font-mono font-semibold text-zinc-100">
  294. {scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
  295. </span>
  296. </div>
  297. {/* NFC status */}
  298. <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
  299. <div className="flex items-center gap-2">
  300. <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  301. <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" />
  302. </svg>
  303. <span className="text-sm text-zinc-500">NFC</span>
  304. </div>
  305. <span className={`text-sm font-medium ${currentTagId ? 'text-green-500' : 'text-zinc-500'}`}>
  306. {currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
  307. </span>
  308. </div>
  309. </div>
  310. </div>
  311. {/* Printer status badges */}
  312. {printers.length > 0 && (
  313. <div className="mt-3 border border-dashed border-zinc-700/50 rounded-xl p-4">
  314. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2.5">
  315. {t('spoolbuddy.dashboard.printers', 'Printers')}
  316. </h2>
  317. <div className="flex flex-wrap gap-2 overflow-hidden">
  318. {printers.map((printer: Printer, i: number) => {
  319. const isOnline = statusQueries[i]?.data?.connected ?? false;
  320. return (
  321. <div
  322. key={printer.id}
  323. className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 rounded-lg"
  324. title={`${printer.name} — ${isOnline ? 'Online' : 'Offline'}`}
  325. >
  326. <div className={`w-2 h-2 rounded-full shrink-0 ${isOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
  327. <span className="text-xs text-zinc-400 truncate max-w-[100px]">{printer.name}</span>
  328. </div>
  329. );
  330. })}
  331. </div>
  332. </div>
  333. )}
  334. </div>
  335. {/* Right column: Current Spool */}
  336. <div className="w-7/12 min-h-0">
  337. <div className="border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col">
  338. <h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0">
  339. {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
  340. </h2>
  341. <div className="flex-1 flex items-center justify-center min-h-0">
  342. {!sbState.deviceOnline ? (
  343. <DeviceOfflineState />
  344. ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
  345. <SpoolInfoCard
  346. spool={(() => {
  347. const s = displayedSpool ?? sbState.matchedSpool!;
  348. return {
  349. id: s.id,
  350. tag_uid: displayedTagId,
  351. material: s.material,
  352. subtype: s.subtype,
  353. color_name: s.color_name,
  354. rgba: s.rgba,
  355. brand: s.brand,
  356. label_weight: s.label_weight,
  357. core_weight: s.core_weight,
  358. weight_used: s.weight_used,
  359. };
  360. })()}
  361. scaleWeight={liveWeight ?? displayedWeight}
  362. onSyncWeight={() => refetchSpools()}
  363. onAssignToAms={() => setShowAssignAmsModal(true)}
  364. onClose={handleCloseSpoolCard}
  365. />
  366. ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
  367. <UnknownTagCard
  368. tagUid={displayedTagId}
  369. scaleWeight={liveWeight ?? displayedWeight}
  370. onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
  371. onAddToInventory={() => setShowQuickAddModal(true)}
  372. onClose={handleCloseSpoolCard}
  373. />
  374. ) : (
  375. <IdleSpool />
  376. )}
  377. </div>
  378. </div>
  379. </div>
  380. </div>
  381. {/* Assign to AMS Modal */}
  382. {displayedSpool && displayedTagId && (
  383. <AssignToAmsModal
  384. isOpen={showAssignAmsModal}
  385. onClose={() => setShowAssignAmsModal(false)}
  386. spool={displayedSpool}
  387. printerId={selectedPrinterId}
  388. />
  389. )}
  390. {/* Link Tag to Spool Modal */}
  391. {displayedTagId && (
  392. <LinkSpoolModal
  393. isOpen={showLinkModal}
  394. onClose={() => setShowLinkModal(false)}
  395. tagId={displayedTagId}
  396. untaggedSpools={untaggedSpools}
  397. onLink={handleLinkTagToSpool}
  398. />
  399. )}
  400. {/* Quick-add to Inventory Modal */}
  401. {showQuickAddModal && displayedTagId && (
  402. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
  403. <div className="bg-zinc-800 rounded-2xl p-6 mx-4 max-w-sm w-full border border-zinc-700">
  404. <h3 className="text-lg font-semibold text-zinc-100 mb-3">
  405. {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
  406. </h3>
  407. {/* Hint */}
  408. <div className="flex gap-2.5 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg mb-4">
  409. <svg className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  410. <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" />
  411. </svg>
  412. <p className="text-sm text-amber-200/80">
  413. {t('spoolbuddy.modal.quickAddHint', 'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use "Link to Spool" here to assign the NFC tag.')}
  414. </p>
  415. </div>
  416. <p className="text-sm text-zinc-400 mb-1">
  417. {t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}
  418. </p>
  419. <p className="text-xs text-zinc-500 font-mono mb-5">{displayedTagId}</p>
  420. <div className="flex gap-3">
  421. <button
  422. onClick={() => setShowQuickAddModal(false)}
  423. 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]"
  424. >
  425. {t('common.cancel', 'Cancel')}
  426. </button>
  427. <button
  428. onClick={handleQuickAddToInventory}
  429. disabled={quickAddBusy}
  430. 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]"
  431. >
  432. {quickAddBusy ? t('common.saving', 'Saving...') : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}
  433. </button>
  434. </div>
  435. </div>
  436. </div>
  437. )}
  438. </div>
  439. );
  440. }