import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react'; import { api, ApiError } from '../api/client'; import type { SpoolmanSyncResult, Printer } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; export function SpoolmanSettings() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [localEnabled, setLocalEnabled] = useState(false); const [localUrl, setLocalUrl] = useState(''); const [localSyncMode, setLocalSyncMode] = useState('auto'); const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false); const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true); const [selectedPrinterId, setSelectedPrinterId] = useState('all'); const [isInitialized, setIsInitialized] = useState(false); const [showAllSkipped, setShowAllSkipped] = useState(false); const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false); const [showSpoolmanAmsSyncConfirm, setShowSpoolmanAmsSyncConfirm] = useState(false); // Fetch Spoolman settings const { data: settings, isLoading: settingsLoading } = useQuery({ queryKey: ['spoolman-settings'], queryFn: api.getSpoolmanSettings, }); // Fetch Spoolman status const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ['spoolman-status'], queryFn: api.getSpoolmanStatus, refetchInterval: 30000, // Refresh every 30 seconds }); // Fetch printers for the dropdown const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Initialize local state from settings useEffect(() => { if (settings) { setLocalEnabled(settings.spoolman_enabled === 'true'); setLocalUrl(settings.spoolman_url || ''); setLocalSyncMode(settings.spoolman_sync_mode || 'auto'); setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true'); setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false'); setIsInitialized(true); } }, [settings]); // Auto-save when settings change (after initial load) // Intentionally omit saveMutation and settings from deps to avoid infinite loops useEffect(() => { if (!isInitialized || !settings) return; const hasChanges = (settings.spoolman_enabled === 'true') !== localEnabled || (settings.spoolman_url || '') !== localUrl || (settings.spoolman_sync_mode || 'auto') !== localSyncMode || (settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync || (settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage; if (hasChanges) { const timeoutId = setTimeout(() => { saveMutation.mutate(); }, 500); return () => clearTimeout(timeoutId); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, isInitialized]); // Save mutation const saveMutation = useMutation({ mutationFn: () => api.updateSpoolmanSettings({ spoolman_enabled: localEnabled ? 'true' : 'false', spoolman_url: localUrl, spoolman_sync_mode: localSyncMode, spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false', spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false', }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-status'] }); queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); queryClient.invalidateQueries({ queryKey: ['settings'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] }); showToast(t('settings.toast.settingsSaved')); }, onError: () => { showToast(t('settings.toast.saveFailed'), 'error'); }, }); // Connect mutation const connectMutation = useMutation({ mutationFn: api.connectSpoolman, onSuccess: () => { refetchStatus(); }, onError: () => { showToast(t('settings.toast.saveFailed'), 'error'); }, }); // Disconnect mutation const disconnectMutation = useMutation({ mutationFn: api.disconnectSpoolman, onSuccess: () => { refetchStatus(); }, onError: () => { showToast(t('settings.toast.saveFailed'), 'error'); }, }); // Sync all mutation const syncAllMutation = useMutation({ mutationFn: api.syncAllPrintersAms, onSuccess: (data: SpoolmanSyncResult) => { showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success'); }, onError: () => { showToast(t('settings.toast.saveFailed'), 'error'); }, }); // Sync single printer mutation const syncPrinterMutation = useMutation({ mutationFn: (printerId: number) => api.syncPrinterAms(printerId), onSuccess: (data: SpoolmanSyncResult) => { showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success'); }, onError: () => { showToast(t('settings.toast.saveFailed'), 'error'); }, }); // Helper to handle sync based on selection const handleSync = () => { if (selectedPrinterId === 'all') { syncAllMutation.mutate(); } else { syncPrinterMutation.mutate(selectedPrinterId); } }; // Inventory AMS weight sync mutation const amsSyncMutation = useMutation({ mutationFn: api.syncWeightsFromAms, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['spools'] }); queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success'); setShowAmsSyncConfirm(false); }, onError: () => { showToast(t('settings.amsSyncError'), 'error'); setShowAmsSyncConfirm(false); }, }); // Spoolman AMS weight sync mutation const spoolmanAmsSyncMutation = useMutation({ mutationFn: api.syncSpoolmanAmsWeights, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success'); setShowSpoolmanAmsSyncConfirm(false); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 503) { showToast(t('settings.spoolmanAmsSyncErrorUnreachable'), 'error'); } else if (error instanceof ApiError && error.status === 400) { showToast(t('settings.spoolmanAmsSyncErrorNotConfigured'), 'error'); } else { showToast(t('settings.spoolmanAmsSyncError'), 'error'); } setShowSpoolmanAmsSyncConfirm(false); }, }); // Combine mutation states const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending; const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data; const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess; if (settingsLoading) { return (

{t('settings.filamentTracking')}

); } return (

{t('settings.filamentTracking')}

{saveMutation.isPending && ( )}

{t('settings.filamentTrackingDesc')}

{/* Mode selector cards */}
{/* Built-in Inventory */} {/* Spoolman */}
{/* Built-in Inventory details */} {!localEnabled && (
  • {t('settings.builtInFeatureRfid')}
  • {t('settings.builtInFeatureUsage')}
  • {t('settings.builtInFeatureCatalog')}
  • {t('settings.builtInFeatureThirdParty')}
)} {/* Spoolman settings - only shown when Spoolman mode is selected */} {localEnabled && (
{/* Info banner about sync requirements */}

{t('settings.howSyncWorks')}

  • {t('settings.syncInfoRfidOnly')}
  • {t('settings.syncInfoAutoCreate')}
  • {t('settings.syncInfoThirdPartySkipped')}

{t('settings.linkingExistingSpools')}

{t('settings.linkingExistingSpoolsDesc')}

{/* URL input */}
setLocalUrl(e.target.value)} 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" />

{t('settings.spoolmanUrlHint')}

{/* Sync mode */}

{localSyncMode === 'auto' ? t('settings.syncModeAutoDesc') : t('settings.syncModeManualDesc')}

{/* Disable Weight Sync toggle - only show when sync mode is auto */} {localSyncMode === 'auto' && (

{t('spoolman.disableWeightSync')}

{t('spoolman.disableWeightSyncDesc')}

)} {/* Report Partial Usage toggle - only show when weight sync is disabled */} {localDisableWeightSync && (

{t('spoolman.reportPartialUsage')}

{t('spoolman.reportPartialUsageDesc')}

)} {/* Connection status */}
{t('settings.status')}: {statusLoading ? ( ) : status?.connected ? ( {t('settings.spoolmanConnected')} ) : ( {t('settings.spoolmanDisconnected')} )}
{status?.connected ? ( ) : ( )}
{/* Error display */} {(connectMutation.isError || disconnectMutation.isError) && (
{((connectMutation.error || disconnectMutation.error) as Error).message}
)} {/* Manual sync section */} {status?.connected && (

{t('settings.syncAmsData')}

{t('settings.syncAmsDataDesc')}

{/* Printer selector */}
{/* Sync button */}
)} {/* Spoolman AMS weight sync */} {status?.connected && (

{t('settings.spoolmanAmsSyncButton')}

{t('settings.spoolmanAmsSyncMessage')}

)} {/* Sync result */} {syncSuccess && syncResult && (
{/* Main result */}
{syncResult.success ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully` : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
{/* Skipped spools */} {syncResult.skipped_count > 0 && (
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
{syncResult.skipped_count > 5 && ( )}
    {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
  • {s.color && ( )} {s.location} - {s.reason}
  • ))} {!showAllSkipped && syncResult.skipped_count > 5 && (
  • ...and {syncResult.skipped_count - 5} more
  • )}
)} {/* Errors */} {syncResult.errors.length > 0 && (
Errors:
    {syncResult.errors.map((err, i) => (
  • {err}
  • ))}
)}
)}
)}
{showAmsSyncConfirm && ( amsSyncMutation.mutate()} onCancel={() => setShowAmsSyncConfirm(false)} /> )} {showSpoolmanAmsSyncConfirm && ( spoolmanAmsSyncMutation.mutate()} onCancel={() => setShowSpoolmanAmsSyncConfirm(false)} /> )}
); }