SpoolmanSettings.tsx 35 KB

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