mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
The app was built dark-first, so hundreds of hardcoded Tailwind semantic text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had no dark: variant. With darkMode:'class' they applied in light theme too, producing washed-out text on pale tints and white cards — including the three reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging banner). Give each a theme-aware pair: a darker readable shade in light theme with the original pinned to dark:, so dark theme is unchanged. ~100 files. The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy kiosk are left untouched. Plain text-white is already theme-aware via the existing index.css .text-white override, so it needed no changes.
675 lines
30 KiB
TypeScript
675 lines
30 KiB
TypeScript
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 [localAutoAddUnknownRfid, setLocalAutoAddUnknownRfid] = useState(true);
|
|
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('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');
|
|
setLocalAutoAddUnknownRfid(settings.auto_add_unknown_rfid !== '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 ||
|
|
(settings.auto_add_unknown_rfid !== 'false') !== localAutoAddUnknownRfid;
|
|
|
|
if (hasChanges) {
|
|
const timeoutId = setTimeout(() => {
|
|
saveMutation.mutate();
|
|
}, 500);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, localAutoAddUnknownRfid, 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',
|
|
auto_add_unknown_rfid: localAutoAddUnknownRfid ? '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 (
|
|
<Card id="card-spoolman">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card id="card-spoolman">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
|
|
</div>
|
|
{saveMutation.isPending && (
|
|
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('settings.filamentTrackingDesc')}
|
|
</p>
|
|
|
|
{/* Mode selector cards */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Built-in Inventory */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocalEnabled(false)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
!localEnabled
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
|
<span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
|
|
{t('settings.trackingModeBuiltIn')}
|
|
</span>
|
|
</div>
|
|
<p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
|
|
{t('settings.trackingModeBuiltInDesc')}
|
|
</p>
|
|
{!localEnabled && (
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Spoolman */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocalEnabled(true)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
localEnabled
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
|
<span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
|
|
Spoolman
|
|
</span>
|
|
</div>
|
|
<p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
|
|
{t('settings.trackingModeSpoolmanDesc')}
|
|
</p>
|
|
{localEnabled && (
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Auto-add unknown RFID toggle — applies to both internal and Spoolman modes */}
|
|
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
|
|
<div className="pr-4">
|
|
<p className="text-white">{t('settings.autoAddUnknownRfid')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('settings.autoAddUnknownRfidDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localAutoAddUnknownRfid}
|
|
onChange={(e) => setLocalAutoAddUnknownRfid(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<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>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Built-in Inventory details */}
|
|
{!localEnabled && (
|
|
<div className="space-y-3">
|
|
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
|
|
<div className="flex gap-2">
|
|
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-bambu-gray">
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
<li>{t('settings.builtInFeatureRfid')}</li>
|
|
<li>{t('settings.builtInFeatureUsage')}</li>
|
|
<li>{t('settings.builtInFeatureCatalog')}</li>
|
|
<li>{t('settings.builtInFeatureThirdParty')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowAmsSyncConfirm(true)}
|
|
disabled={amsSyncMutation.isPending}
|
|
>
|
|
{amsSyncMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
)}
|
|
{t('settings.amsSyncButton')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spoolman settings - only shown when Spoolman mode is selected */}
|
|
{localEnabled && (
|
|
<div className="space-y-4">
|
|
{/* Info banner about sync requirements */}
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-300 dark:border-blue-500/30 rounded-lg">
|
|
<div className="flex gap-2">
|
|
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-blue-700 dark:text-blue-300">
|
|
<p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
|
|
<ul className="list-disc list-inside space-y-0.5 text-blue-700/90 dark:text-blue-300/80">
|
|
<li>{t('settings.syncInfoRfidOnly')}</li>
|
|
<li>{t('settings.syncInfoAutoCreate')}</li>
|
|
<li>{t('settings.syncInfoThirdPartySkipped')}</li>
|
|
</ul>
|
|
<p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
|
|
<p className="text-blue-700/90 dark:text-blue-300/80">
|
|
{t('settings.linkingExistingSpoolsDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL input */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{t('settings.spoolmanUrl')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="http://192.168.1.100:7912"
|
|
value={localUrl}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{t('settings.spoolmanUrlHint')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sync mode */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{t('settings.syncMode')}
|
|
</label>
|
|
<select
|
|
value={localSyncMode}
|
|
onChange={(e) => setLocalSyncMode(e.target.value)}
|
|
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"
|
|
>
|
|
<option value="auto">{t('settings.syncModeAuto')}</option>
|
|
<option value="manual">{t('settings.syncModeManual')}</option>
|
|
</select>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{localSyncMode === 'auto'
|
|
? t('settings.syncModeAutoDesc')
|
|
: t('settings.syncModeManualDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
|
|
{localSyncMode === 'auto' && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('spoolman.disableWeightSyncDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localDisableWeightSync}
|
|
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<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>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
|
|
{localDisableWeightSync && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('spoolman.reportPartialUsageDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localReportPartialUsage}
|
|
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<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>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection status */}
|
|
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-bambu-gray">{t('settings.status')}:</span>
|
|
{statusLoading ? (
|
|
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
|
) : status?.connected ? (
|
|
<span className="flex items-center gap-1 text-sm text-green-500">
|
|
<Check className="w-4 h-4" />
|
|
{t('settings.spoolmanConnected')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-sm text-red-500">
|
|
<X className="w-4 h-4" />
|
|
{t('settings.spoolmanDisconnected')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{status?.connected ? (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => disconnectMutation.mutate()}
|
|
disabled={disconnectMutation.isPending}
|
|
>
|
|
{disconnectMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Link2Off className="w-4 h-4" />
|
|
)}
|
|
{t('settings.disconnect')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => connectMutation.mutate()}
|
|
disabled={connectMutation.isPending || !localUrl}
|
|
>
|
|
{connectMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Link2 className="w-4 h-4" />
|
|
)}
|
|
{t('settings.connect')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error display */}
|
|
{(connectMutation.isError || disconnectMutation.isError) && (
|
|
<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">
|
|
{((connectMutation.error || disconnectMutation.error) as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual sync section */}
|
|
{status?.connected && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-sm text-white">{t('settings.syncAmsData')}</p>
|
|
<p className="text-xs text-bambu-gray">
|
|
{t('settings.syncAmsDataDesc')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Printer selector */}
|
|
<div className="relative flex-1">
|
|
<select
|
|
value={selectedPrinterId}
|
|
onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
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"
|
|
>
|
|
<option value="all">{t('settings.allPrinters')}</option>
|
|
{printers?.map((printer: Printer) => (
|
|
<option key={printer.id} value={printer.id}>
|
|
{printer.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
|
</div>
|
|
{/* Sync button */}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={handleSync}
|
|
disabled={isSyncing}
|
|
>
|
|
{isSyncing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4" />
|
|
)}
|
|
{t('spoolman.sync')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spoolman AMS weight sync */}
|
|
{status?.connected && (
|
|
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('settings.spoolmanAmsSyncButton')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('settings.spoolmanAmsSyncMessage')}</p>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowSpoolmanAmsSyncConfirm(true)}
|
|
disabled={spoolmanAmsSyncMutation.isPending}
|
|
>
|
|
{spoolmanAmsSyncMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4" />
|
|
)}
|
|
{spoolmanAmsSyncMutation.isPending ? t('settings.spoolmanAmsSyncing') : t('settings.spoolmanAmsSyncButton')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sync result */}
|
|
{syncSuccess && syncResult && (
|
|
<div className="mt-3 space-y-2">
|
|
{/* Main result */}
|
|
<div
|
|
className={`p-2 rounded text-sm ${
|
|
syncResult.success
|
|
? 'bg-green-100 dark:bg-green-500/20 border border-green-300 dark:border-green-500/50 text-green-700 dark:text-green-400'
|
|
: 'bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-500/50 text-yellow-700 dark:text-yellow-400'
|
|
}`}
|
|
>
|
|
{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' : ''}`}
|
|
</div>
|
|
|
|
{/* Skipped spools */}
|
|
{syncResult.skipped_count > 0 && (
|
|
<div className="p-2 bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30 rounded text-sm">
|
|
<div className="flex items-center justify-between text-amber-700 dark:text-amber-400 mb-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
<span className="font-medium">
|
|
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
|
|
</span>
|
|
</div>
|
|
{syncResult.skipped_count > 5 && (
|
|
<button
|
|
onClick={() => setShowAllSkipped(!showAllSkipped)}
|
|
className="text-xs text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 underline"
|
|
>
|
|
{showAllSkipped ? 'Show less' : 'Show all'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ul className="text-xs text-amber-700/90 dark:text-amber-300/80 space-y-0.5">
|
|
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
|
|
<li key={i} className="flex items-center gap-2">
|
|
{s.color && (
|
|
<span
|
|
className="w-3 h-3 rounded-full border border-black/20"
|
|
style={{ backgroundColor: `#${s.color}` }}
|
|
/>
|
|
)}
|
|
<span>{s.location}</span>
|
|
<span className="text-amber-700/70 dark:text-amber-300/60">- {s.reason}</span>
|
|
</li>
|
|
))}
|
|
{!showAllSkipped && syncResult.skipped_count > 5 && (
|
|
<li className="text-amber-700/70 dark:text-amber-300/60 italic">
|
|
...and {syncResult.skipped_count - 5} more
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{syncResult.errors.length > 0 && (
|
|
<div className="p-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded text-sm">
|
|
<div className="text-red-700 dark:text-red-400 font-medium mb-1">Errors:</div>
|
|
<ul className="text-xs text-red-700/90 dark:text-red-300/80 space-y-0.5">
|
|
{syncResult.errors.map((err, i) => (
|
|
<li key={i}>{err}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
{showAmsSyncConfirm && (
|
|
<ConfirmModal
|
|
title={t('settings.amsSyncTitle')}
|
|
message={t('settings.amsSyncMessage')}
|
|
confirmText={t('settings.amsSyncButton')}
|
|
variant="warning"
|
|
isLoading={amsSyncMutation.isPending}
|
|
loadingText={t('settings.amsSyncing')}
|
|
onConfirm={() => amsSyncMutation.mutate()}
|
|
onCancel={() => setShowAmsSyncConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{showSpoolmanAmsSyncConfirm && (
|
|
<ConfirmModal
|
|
title={t('settings.spoolmanAmsSyncTitle')}
|
|
message={t('settings.spoolmanAmsSyncMessage')}
|
|
confirmText={t('settings.spoolmanAmsSyncButton')}
|
|
variant="warning"
|
|
isLoading={spoolmanAmsSyncMutation.isPending}
|
|
loadingText={t('settings.spoolmanAmsSyncing')}
|
|
onConfirm={() => spoolmanAmsSyncMutation.mutate()}
|
|
onCancel={() => setShowSpoolmanAmsSyncConfirm(false)}
|
|
/>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|