SpoolmanSettings.tsx 35 KB

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