SpoolmanSettings.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. import { useState, useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { Loader2, Check, X, RefreshCw, Link2, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
  5. import { api, ApiError } from '../api/client';
  6. import type { SpoolmanSyncResult, Printer } from '../api/client';
  7. import { Card, CardContent, CardHeader } from './Card';
  8. import { Button } from './Button';
  9. import { ConfirmModal } from './ConfirmModal';
  10. import { useToast } from '../contexts/ToastContext';
  11. export function SpoolmanSettings() {
  12. const { t } = useTranslation();
  13. const queryClient = useQueryClient();
  14. const { showToast } = useToast();
  15. const [localEnabled, setLocalEnabled] = useState(false);
  16. const [localUrl, setLocalUrl] = useState('');
  17. const [localSyncMode, setLocalSyncMode] = useState('auto');
  18. const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
  19. const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
  20. const [localAutoAddUnknownRfid, setLocalAutoAddUnknownRfid] = useState(true);
  21. const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
  22. const [isInitialized, setIsInitialized] = useState(false);
  23. const [showAllSkipped, setShowAllSkipped] = useState(false);
  24. const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
  25. const [showSpoolmanAmsSyncConfirm, setShowSpoolmanAmsSyncConfirm] = useState(false);
  26. // Fetch Spoolman settings
  27. const { data: settings, isLoading: settingsLoading } = useQuery({
  28. queryKey: ['spoolman-settings'],
  29. queryFn: api.getSpoolmanSettings,
  30. });
  31. // Fetch Spoolman status
  32. const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
  33. queryKey: ['spoolman-status'],
  34. queryFn: api.getSpoolmanStatus,
  35. refetchInterval: 30000, // Refresh every 30 seconds
  36. });
  37. // Fetch printers for the dropdown
  38. const { data: printers } = useQuery({
  39. queryKey: ['printers'],
  40. queryFn: api.getPrinters,
  41. });
  42. // Initialize local state from settings
  43. useEffect(() => {
  44. if (settings) {
  45. setLocalEnabled(settings.spoolman_enabled === 'true');
  46. setLocalUrl(settings.spoolman_url || '');
  47. setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
  48. setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');
  49. setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');
  50. setLocalAutoAddUnknownRfid(settings.auto_add_unknown_rfid !== 'false');
  51. setIsInitialized(true);
  52. }
  53. }, [settings]);
  54. // Auto-save when settings change (after initial load)
  55. // Intentionally omit saveMutation and settings from deps to avoid infinite loops
  56. useEffect(() => {
  57. if (!isInitialized || !settings) return;
  58. const hasChanges =
  59. (settings.spoolman_enabled === 'true') !== localEnabled ||
  60. (settings.spoolman_url || '') !== localUrl ||
  61. (settings.spoolman_sync_mode || 'auto') !== localSyncMode ||
  62. (settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||
  63. (settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage ||
  64. (settings.auto_add_unknown_rfid !== 'false') !== localAutoAddUnknownRfid;
  65. if (hasChanges) {
  66. const timeoutId = setTimeout(() => {
  67. saveMutation.mutate();
  68. }, 500);
  69. return () => clearTimeout(timeoutId);
  70. }
  71. // eslint-disable-next-line react-hooks/exhaustive-deps
  72. }, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, localAutoAddUnknownRfid, isInitialized]);
  73. // Save mutation
  74. const saveMutation = useMutation({
  75. mutationFn: () =>
  76. api.updateSpoolmanSettings({
  77. spoolman_enabled: localEnabled ? 'true' : 'false',
  78. spoolman_url: localUrl,
  79. spoolman_sync_mode: localSyncMode,
  80. spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',
  81. spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',
  82. auto_add_unknown_rfid: localAutoAddUnknownRfid ? 'true' : 'false',
  83. }),
  84. onSuccess: () => {
  85. queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
  86. queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
  87. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  88. queryClient.invalidateQueries({ queryKey: ['settings'] });
  89. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
  90. showToast(t('settings.toast.settingsSaved'));
  91. },
  92. onError: () => {
  93. showToast(t('settings.toast.saveFailed'), 'error');
  94. },
  95. });
  96. // Connect mutation
  97. const connectMutation = useMutation({
  98. mutationFn: api.connectSpoolman,
  99. onSuccess: () => {
  100. refetchStatus();
  101. },
  102. onError: () => {
  103. showToast(t('settings.toast.saveFailed'), 'error');
  104. },
  105. });
  106. // Sync all mutation
  107. const syncAllMutation = useMutation({
  108. mutationFn: api.syncAllPrintersAms,
  109. onSuccess: (data: SpoolmanSyncResult) => {
  110. showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
  111. },
  112. onError: () => {
  113. showToast(t('settings.toast.saveFailed'), 'error');
  114. },
  115. });
  116. // Sync single printer mutation
  117. const syncPrinterMutation = useMutation({
  118. mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
  119. onSuccess: (data: SpoolmanSyncResult) => {
  120. showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
  121. },
  122. onError: () => {
  123. showToast(t('settings.toast.saveFailed'), 'error');
  124. },
  125. });
  126. // Helper to handle sync based on selection
  127. const handleSync = () => {
  128. if (selectedPrinterId === 'all') {
  129. syncAllMutation.mutate();
  130. } else {
  131. syncPrinterMutation.mutate(selectedPrinterId);
  132. }
  133. };
  134. // Inventory AMS weight sync mutation
  135. const amsSyncMutation = useMutation({
  136. mutationFn: api.syncWeightsFromAms,
  137. onSuccess: (data) => {
  138. queryClient.invalidateQueries({ queryKey: ['spools'] });
  139. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  140. showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
  141. setShowAmsSyncConfirm(false);
  142. },
  143. onError: () => {
  144. showToast(t('settings.amsSyncError'), 'error');
  145. setShowAmsSyncConfirm(false);
  146. },
  147. });
  148. // Spoolman AMS weight sync mutation
  149. const spoolmanAmsSyncMutation = useMutation({
  150. mutationFn: api.syncSpoolmanAmsWeights,
  151. onSuccess: (data) => {
  152. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  153. showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
  154. setShowSpoolmanAmsSyncConfirm(false);
  155. },
  156. onError: (error: Error) => {
  157. if (error instanceof ApiError && error.status === 503) {
  158. showToast(t('settings.spoolmanAmsSyncErrorUnreachable'), 'error');
  159. } else if (error instanceof ApiError && error.status === 400) {
  160. showToast(t('settings.spoolmanAmsSyncErrorNotConfigured'), 'error');
  161. } else {
  162. showToast(t('settings.spoolmanAmsSyncError'), 'error');
  163. }
  164. setShowSpoolmanAmsSyncConfirm(false);
  165. },
  166. });
  167. // Combine mutation states
  168. const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
  169. const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
  170. const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
  171. if (settingsLoading) {
  172. return (
  173. <Card id="card-spoolman">
  174. <CardHeader>
  175. <div className="flex items-center gap-2">
  176. <Database className="w-5 h-5 text-bambu-green" />
  177. <h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
  178. </div>
  179. </CardHeader>
  180. <CardContent>
  181. <div className="flex justify-center py-8">
  182. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  183. </div>
  184. </CardContent>
  185. </Card>
  186. );
  187. }
  188. return (
  189. <Card id="card-spoolman">
  190. <CardHeader>
  191. <div className="flex items-center justify-between">
  192. <div className="flex items-center gap-2">
  193. <Database className="w-5 h-5 text-bambu-green" />
  194. <h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
  195. </div>
  196. {saveMutation.isPending && (
  197. <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
  198. )}
  199. </div>
  200. </CardHeader>
  201. <CardContent className="space-y-4">
  202. <p className="text-sm text-bambu-gray">
  203. {t('settings.filamentTrackingDesc')}
  204. </p>
  205. {/* Mode selector cards */}
  206. <div className="grid grid-cols-2 gap-3">
  207. {/* Built-in Inventory */}
  208. <button
  209. type="button"
  210. onClick={() => setLocalEnabled(false)}
  211. className={`p-3 rounded-lg border-2 text-left transition-colors ${
  212. !localEnabled
  213. ? 'border-bambu-green bg-bambu-green/10'
  214. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
  215. }`}
  216. >
  217. <div className="flex items-center gap-2 mb-1.5">
  218. <Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  219. <span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
  220. {t('settings.trackingModeBuiltIn')}
  221. </span>
  222. </div>
  223. <p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
  224. {t('settings.trackingModeBuiltInDesc')}
  225. </p>
  226. {!localEnabled && (
  227. <div className="flex items-center gap-1 mt-2">
  228. <Check className="w-3 h-3 text-bambu-green" />
  229. <span className="text-xs text-bambu-green">{t('common.enabled')}</span>
  230. </div>
  231. )}
  232. </button>
  233. {/* Spoolman */}
  234. <button
  235. type="button"
  236. onClick={() => setLocalEnabled(true)}
  237. className={`p-3 rounded-lg border-2 text-left transition-colors ${
  238. localEnabled
  239. ? 'border-bambu-green bg-bambu-green/10'
  240. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
  241. }`}
  242. >
  243. <div className="flex items-center gap-2 mb-1.5">
  244. <ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  245. <span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
  246. Spoolman
  247. </span>
  248. </div>
  249. <p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
  250. {t('settings.trackingModeSpoolmanDesc')}
  251. </p>
  252. {localEnabled && (
  253. <div className="flex items-center gap-1 mt-2">
  254. <Check className="w-3 h-3 text-bambu-green" />
  255. <span className="text-xs text-bambu-green">{t('common.enabled')}</span>
  256. </div>
  257. )}
  258. </button>
  259. </div>
  260. {/* Auto-add unknown RFID toggle — applies to both internal and Spoolman modes */}
  261. <div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
  262. <div className="pr-4">
  263. <p className="text-white">{t('settings.autoAddUnknownRfid')}</p>
  264. <p className="text-sm text-bambu-gray">
  265. {t('settings.autoAddUnknownRfidDesc')}
  266. </p>
  267. </div>
  268. <label className="relative inline-flex items-center cursor-pointer">
  269. <input
  270. type="checkbox"
  271. checked={localAutoAddUnknownRfid}
  272. onChange={(e) => setLocalAutoAddUnknownRfid(e.target.checked)}
  273. className="sr-only peer"
  274. />
  275. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  276. </label>
  277. </div>
  278. {/* Built-in Inventory details */}
  279. {!localEnabled && (
  280. <div className="space-y-3">
  281. <div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
  282. <div className="flex gap-2">
  283. <Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
  284. <div className="text-xs text-bambu-gray">
  285. <ul className="list-disc list-inside space-y-0.5">
  286. <li>{t('settings.builtInFeatureRfid')}</li>
  287. <li>{t('settings.builtInFeatureUsage')}</li>
  288. <li>{t('settings.builtInFeatureCatalog')}</li>
  289. <li>{t('settings.builtInFeatureThirdParty')}</li>
  290. </ul>
  291. </div>
  292. </div>
  293. </div>
  294. <Button
  295. variant="secondary"
  296. size="sm"
  297. onClick={() => setShowAmsSyncConfirm(true)}
  298. disabled={amsSyncMutation.isPending}
  299. >
  300. {amsSyncMutation.isPending ? (
  301. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  302. ) : (
  303. <RefreshCw className="w-4 h-4 mr-2" />
  304. )}
  305. {t('settings.amsSyncButton')}
  306. </Button>
  307. </div>
  308. )}
  309. {/* Spoolman settings - only shown when Spoolman mode is selected */}
  310. {localEnabled && (
  311. <div className="space-y-4">
  312. {/* Info banner about sync requirements */}
  313. <div className="p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-300 dark:border-blue-500/30 rounded-lg">
  314. <div className="flex gap-2">
  315. <Info className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
  316. <div className="text-xs text-blue-700 dark:text-blue-300">
  317. <p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
  318. <ul className="list-disc list-inside space-y-0.5 text-blue-700/90 dark:text-blue-300/80">
  319. <li>{t('settings.syncInfoRfidOnly')}</li>
  320. <li>{t('settings.syncInfoAutoCreate')}</li>
  321. <li>{t('settings.syncInfoThirdPartySkipped')}</li>
  322. </ul>
  323. <p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
  324. <p className="text-blue-700/90 dark:text-blue-300/80">
  325. {t('settings.linkingExistingSpoolsDesc')}
  326. </p>
  327. </div>
  328. </div>
  329. </div>
  330. {/* URL input */}
  331. <div>
  332. <label className="block text-sm text-bambu-gray mb-1">
  333. {t('settings.spoolmanUrl')}
  334. </label>
  335. <input
  336. type="text"
  337. placeholder="http://192.168.1.100:7912"
  338. value={localUrl}
  339. onChange={(e) => setLocalUrl(e.target.value)}
  340. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
  341. />
  342. <p className="text-xs text-bambu-gray mt-1">
  343. {t('settings.spoolmanUrlHint')}
  344. </p>
  345. </div>
  346. {/* Sync mode */}
  347. <div>
  348. <label className="block text-sm text-bambu-gray mb-1">
  349. {t('settings.syncMode')}
  350. </label>
  351. <select
  352. value={localSyncMode}
  353. onChange={(e) => setLocalSyncMode(e.target.value)}
  354. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  355. >
  356. <option value="auto">{t('settings.syncModeAuto')}</option>
  357. <option value="manual">{t('settings.syncModeManual')}</option>
  358. </select>
  359. <p className="text-xs text-bambu-gray mt-1">
  360. {localSyncMode === 'auto'
  361. ? t('settings.syncModeAutoDesc')
  362. : t('settings.syncModeManualDesc')}
  363. </p>
  364. </div>
  365. {/* Disable Weight Sync toggle - only show when sync mode is auto */}
  366. {localSyncMode === 'auto' && (
  367. <div className="flex items-center justify-between">
  368. <div>
  369. <p className="text-white">{t('spoolman.disableWeightSync')}</p>
  370. <p className="text-sm text-bambu-gray">
  371. {t('spoolman.disableWeightSyncDesc')}
  372. </p>
  373. </div>
  374. <label className="relative inline-flex items-center cursor-pointer">
  375. <input
  376. type="checkbox"
  377. checked={localDisableWeightSync}
  378. onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
  379. className="sr-only peer"
  380. />
  381. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  382. </label>
  383. </div>
  384. )}
  385. {/* Report Partial Usage toggle - only show when weight sync is disabled */}
  386. {localDisableWeightSync && (
  387. <div className="flex items-center justify-between">
  388. <div>
  389. <p className="text-white">{t('spoolman.reportPartialUsage')}</p>
  390. <p className="text-sm text-bambu-gray">
  391. {t('spoolman.reportPartialUsageDesc')}
  392. </p>
  393. </div>
  394. <label className="relative inline-flex items-center cursor-pointer">
  395. <input
  396. type="checkbox"
  397. checked={localReportPartialUsage}
  398. onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
  399. className="sr-only peer"
  400. />
  401. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  402. </label>
  403. </div>
  404. )}
  405. {/* Connection status */}
  406. <div className="pt-2 border-t border-bambu-dark-tertiary">
  407. <div className="flex items-center justify-between mb-3">
  408. <div className="flex items-center gap-2">
  409. <span className="text-sm text-bambu-gray">{t('settings.status')}:</span>
  410. {statusLoading ? (
  411. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  412. ) : status?.connected ? (
  413. <span className="flex items-center gap-1 text-sm text-green-500">
  414. <Check className="w-4 h-4" />
  415. {t('settings.spoolmanConnected')}
  416. </span>
  417. ) : (
  418. <span className="flex items-center gap-1 text-sm text-red-500">
  419. <X className="w-4 h-4" />
  420. {t('settings.spoolmanDisconnected')}
  421. </span>
  422. )}
  423. </div>
  424. {/* Retry affordance only. Spoolman is a stateless HTTP API with
  425. no session to hold open, so there is nothing for a Disconnect
  426. button to disconnect: it closed this process's client object,
  427. which any other request rebuilt lazily moments later, and the
  428. status above silently flipped back. The enable toggle is what
  429. turns the integration off. */}
  430. <div className="flex gap-2">
  431. {!status?.connected && (
  432. <Button
  433. size="sm"
  434. onClick={() => connectMutation.mutate()}
  435. disabled={connectMutation.isPending || !localUrl}
  436. >
  437. {connectMutation.isPending ? (
  438. <Loader2 className="w-4 h-4 animate-spin" />
  439. ) : (
  440. <Link2 className="w-4 h-4" />
  441. )}
  442. {t('settings.connect')}
  443. </Button>
  444. )}
  445. </div>
  446. </div>
  447. {/* Error display */}
  448. {connectMutation.isError && (
  449. <div className="mb-3 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
  450. {(connectMutation.error as Error).message}
  451. </div>
  452. )}
  453. {/* Manual sync section */}
  454. {status?.connected && (
  455. <div className="space-y-3">
  456. <div>
  457. <p className="text-sm text-white">{t('settings.syncAmsData')}</p>
  458. <p className="text-xs text-bambu-gray">
  459. {t('settings.syncAmsDataDesc')}
  460. </p>
  461. </div>
  462. <div className="flex items-center gap-2">
  463. {/* Printer selector */}
  464. <div className="relative flex-1">
  465. <select
  466. value={selectedPrinterId}
  467. onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
  468. className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  469. >
  470. <option value="all">{t('settings.allPrinters')}</option>
  471. {printers?.map((printer: Printer) => (
  472. <option key={printer.id} value={printer.id}>
  473. {printer.name}
  474. </option>
  475. ))}
  476. </select>
  477. <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  478. </div>
  479. {/* Sync button */}
  480. <Button
  481. variant="secondary"
  482. size="sm"
  483. onClick={handleSync}
  484. disabled={isSyncing}
  485. >
  486. {isSyncing ? (
  487. <Loader2 className="w-4 h-4 animate-spin" />
  488. ) : (
  489. <RefreshCw className="w-4 h-4" />
  490. )}
  491. {t('spoolman.sync')}
  492. </Button>
  493. </div>
  494. </div>
  495. )}
  496. {/* Spoolman AMS weight sync */}
  497. {status?.connected && (
  498. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
  499. <div className="flex items-center justify-between">
  500. <div>
  501. <p className="text-sm text-white">{t('settings.spoolmanAmsSyncButton')}</p>
  502. <p className="text-xs text-bambu-gray">{t('settings.spoolmanAmsSyncMessage')}</p>
  503. </div>
  504. <Button
  505. variant="secondary"
  506. size="sm"
  507. onClick={() => setShowSpoolmanAmsSyncConfirm(true)}
  508. disabled={spoolmanAmsSyncMutation.isPending}
  509. >
  510. {spoolmanAmsSyncMutation.isPending ? (
  511. <Loader2 className="w-4 h-4 animate-spin" />
  512. ) : (
  513. <RefreshCw className="w-4 h-4" />
  514. )}
  515. {spoolmanAmsSyncMutation.isPending ? t('settings.spoolmanAmsSyncing') : t('settings.spoolmanAmsSyncButton')}
  516. </Button>
  517. </div>
  518. </div>
  519. )}
  520. {/* Sync result */}
  521. {syncSuccess && syncResult && (
  522. <div className="mt-3 space-y-2">
  523. {/* Main result */}
  524. <div
  525. className={`p-2 rounded text-sm ${
  526. syncResult.success
  527. ? 'bg-green-100 dark:bg-green-500/20 border border-green-300 dark:border-green-500/50 text-green-700 dark:text-green-400'
  528. : 'bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-500/50 text-yellow-700 dark:text-yellow-400'
  529. }`}
  530. >
  531. {syncResult.success
  532. ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
  533. : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
  534. </div>
  535. {/* Skipped spools */}
  536. {syncResult.skipped_count > 0 && (
  537. <div className="p-2 bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30 rounded text-sm">
  538. <div className="flex items-center justify-between text-amber-700 dark:text-amber-400 mb-1">
  539. <div className="flex items-center gap-1.5">
  540. <AlertTriangle className="w-3.5 h-3.5" />
  541. <span className="font-medium">
  542. {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
  543. </span>
  544. </div>
  545. {syncResult.skipped_count > 5 && (
  546. <button
  547. onClick={() => setShowAllSkipped(!showAllSkipped)}
  548. className="text-xs text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 underline"
  549. >
  550. {showAllSkipped ? 'Show less' : 'Show all'}
  551. </button>
  552. )}
  553. </div>
  554. <ul className="text-xs text-amber-700/90 dark:text-amber-300/80 space-y-0.5">
  555. {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
  556. <li key={i} className="flex items-center gap-2">
  557. {s.color && (
  558. <span
  559. className="w-3 h-3 rounded-full border border-black/20"
  560. style={{ backgroundColor: `#${s.color}` }}
  561. />
  562. )}
  563. <span>{s.location}</span>
  564. <span className="text-amber-700/70 dark:text-amber-300/60">- {s.reason}</span>
  565. </li>
  566. ))}
  567. {!showAllSkipped && syncResult.skipped_count > 5 && (
  568. <li className="text-amber-700/70 dark:text-amber-300/60 italic">
  569. ...and {syncResult.skipped_count - 5} more
  570. </li>
  571. )}
  572. </ul>
  573. </div>
  574. )}
  575. {/* Errors */}
  576. {syncResult.errors.length > 0 && (
  577. <div className="p-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded text-sm">
  578. <div className="text-red-700 dark:text-red-400 font-medium mb-1">Errors:</div>
  579. <ul className="text-xs text-red-700/90 dark:text-red-300/80 space-y-0.5">
  580. {syncResult.errors.map((err, i) => (
  581. <li key={i}>{err}</li>
  582. ))}
  583. </ul>
  584. </div>
  585. )}
  586. </div>
  587. )}
  588. </div>
  589. </div>
  590. )}
  591. </CardContent>
  592. {showAmsSyncConfirm && (
  593. <ConfirmModal
  594. title={t('settings.amsSyncTitle')}
  595. message={t('settings.amsSyncMessage')}
  596. confirmText={t('settings.amsSyncButton')}
  597. variant="warning"
  598. isLoading={amsSyncMutation.isPending}
  599. loadingText={t('settings.amsSyncing')}
  600. onConfirm={() => amsSyncMutation.mutate()}
  601. onCancel={() => setShowAmsSyncConfirm(false)}
  602. />
  603. )}
  604. {showSpoolmanAmsSyncConfirm && (
  605. <ConfirmModal
  606. title={t('settings.spoolmanAmsSyncTitle')}
  607. message={t('settings.spoolmanAmsSyncMessage')}
  608. confirmText={t('settings.spoolmanAmsSyncButton')}
  609. variant="warning"
  610. isLoading={spoolmanAmsSyncMutation.isPending}
  611. loadingText={t('settings.spoolmanAmsSyncing')}
  612. onConfirm={() => spoolmanAmsSyncMutation.mutate()}
  613. onCancel={() => setShowSpoolmanAmsSyncConfirm(false)}
  614. />
  615. )}
  616. </Card>
  617. );
  618. }