mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
[Feature] Spoolbuddy Fixes and Improvements (#787)
[Feature] Spoolbuddy Fixes and Improvements (#787)
This commit is contained in:
@@ -4996,6 +4996,7 @@ export interface SpoolBuddyDevice {
|
||||
device_id: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
backend_url?: string | null;
|
||||
firmware_version: string | null;
|
||||
has_nfc: boolean;
|
||||
has_scale: boolean;
|
||||
@@ -5055,6 +5056,12 @@ export const spoolbuddyApi = {
|
||||
body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
|
||||
}),
|
||||
|
||||
updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>
|
||||
request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),
|
||||
}),
|
||||
|
||||
checkDaemonUpdate: (deviceId: string) =>
|
||||
request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
|
||||
|
||||
@@ -5078,6 +5085,18 @@ export const spoolbuddyApi = {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
|
||||
queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
|
||||
request<{ status: string; diagnostic: string; message: string }>(
|
||||
`/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,
|
||||
{ method: 'POST', body: '{}' }
|
||||
),
|
||||
|
||||
getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
|
||||
request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(
|
||||
`/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,
|
||||
{ method: 'GET' }
|
||||
),
|
||||
};
|
||||
|
||||
export interface BugReportRequest {
|
||||
|
||||
@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
|
||||
spool?: InventorySpool | null;
|
||||
printersWithCalibrations?: PrinterWithCalibrations[];
|
||||
currencySymbol: string;
|
||||
onSpoolsCreated?: (spools: InventorySpool[]) => void;
|
||||
}
|
||||
|
||||
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
|
||||
export function SpoolFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
spool,
|
||||
printersWithCalibrations = [],
|
||||
currencySymbol,
|
||||
onSpoolsCreated,
|
||||
}: SpoolFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
await saveKProfiles(newSpool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
|
||||
showToast(t('inventory.spoolCreated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
}
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated(newSpools);
|
||||
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Play, RotateCw } from 'lucide-react';
|
||||
import { spoolbuddyApi } from '../../api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DiagnosticModalProps {
|
||||
type: 'scale' | 'nfc' | 'read_tag';
|
||||
deviceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isRunning) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isRunning, onClose]);
|
||||
|
||||
const runDiagnostic = useCallback(async () => {
|
||||
setIsRunning(true);
|
||||
setOutput('');
|
||||
setError('');
|
||||
setHasRun(true);
|
||||
|
||||
try {
|
||||
// Step 1: Queue the diagnostic on the device
|
||||
setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
|
||||
await spoolbuddyApi.queueDiagnostics(deviceId, type);
|
||||
|
||||
// Step 2: Poll for results with timeout
|
||||
let result = null;
|
||||
const maxRetries = 60; // 30s timeout with 500ms polling
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries && !result) {
|
||||
// Wait a bit before polling
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
try {
|
||||
result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
|
||||
break;
|
||||
} catch (e) {
|
||||
// Not ready yet, continue polling
|
||||
retryCount++;
|
||||
if (retryCount % 4 === 0) {
|
||||
// Update every 2 seconds (after 4 retries of 500ms)
|
||||
setOutput(prev => prev + '.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Diagnostic timed out - device did not report results');
|
||||
}
|
||||
|
||||
setOutput(result.output);
|
||||
if (!result.success) {
|
||||
setError(`Exit code: ${result.exit_code}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
setOutput('');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [type, deviceId]);
|
||||
|
||||
const title = type === 'scale'
|
||||
? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
|
||||
: type === 'read_tag'
|
||||
? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
|
||||
: t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 border-b border-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
|
||||
<span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
|
||||
</div>
|
||||
) : output ? (
|
||||
<>
|
||||
<div className="text-green-400 whitespace-pre-wrap break-words">
|
||||
{output}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-400 mt-2">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : hasRun ? (
|
||||
<div>
|
||||
{error ? (
|
||||
<div className="text-red-400">ERROR: {error}</div>
|
||||
) : (
|
||||
<span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-500">
|
||||
{t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
|
||||
<button
|
||||
onClick={runDiagnostic}
|
||||
disabled={isRunning}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
{t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
|
||||
</>
|
||||
) : hasRun ? (
|
||||
<>
|
||||
<RotateCw size={16} />
|
||||
{t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
{t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
|
||||
>
|
||||
{t('spoolbuddy.diagnostic.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -37,6 +37,11 @@ export function SpoolBuddyLayout() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const device = devices[0];
|
||||
const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
|
||||
const sbStateForUi = useMemo(
|
||||
() => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
|
||||
[sbState, effectiveDeviceOnline]
|
||||
);
|
||||
|
||||
// Sync display settings from device on initial load
|
||||
const initializedRef = useRef(false);
|
||||
@@ -69,14 +74,14 @@ export function SpoolBuddyLayout() {
|
||||
|
||||
// Update alert based on device state and available updates
|
||||
useEffect(() => {
|
||||
if (!sbState.deviceOnline) {
|
||||
if (!effectiveDeviceOnline) {
|
||||
setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
|
||||
} else if (updateCheck?.update_available && updateCheck.latest_version) {
|
||||
setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
|
||||
} else {
|
||||
setAlert(null);
|
||||
}
|
||||
}, [sbState.deviceOnline, updateCheck]);
|
||||
}, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
|
||||
|
||||
// Track user activity for screen blank
|
||||
const resetActivity = useCallback(() => {
|
||||
@@ -118,12 +123,12 @@ export function SpoolBuddyLayout() {
|
||||
<SpoolBuddyTopBar
|
||||
selectedPrinterId={selectedPrinterId}
|
||||
onPrinterChange={setSelectedPrinterId}
|
||||
deviceOnline={sbState.deviceOnline}
|
||||
deviceOnline={effectiveDeviceOnline}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet context={{
|
||||
selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
|
||||
selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
|
||||
displayBrightness, setDisplayBrightness,
|
||||
displayBlankTimeout, setDisplayBlankTimeout,
|
||||
}} />
|
||||
|
||||
@@ -4467,6 +4467,19 @@ export default {
|
||||
deviceInfo: 'Device Info',
|
||||
hostname: 'Host',
|
||||
uptime: 'Uptime',
|
||||
systemConfig: 'Backend & Auth',
|
||||
backendUrl: 'Bambuddy Backend URL',
|
||||
apiToken: 'API Token',
|
||||
apiTokenPlaceholder: 'Enter API token',
|
||||
saveConfig: 'Save Config',
|
||||
systemQueued: 'Config queued.',
|
||||
nfcDiagnostic: 'NFC Diagnostic',
|
||||
scaleDiagnostic: 'Scale Diagnostic',
|
||||
readTagDiagnostic: 'Read Tag Diagnostic',
|
||||
testNfc: 'Test reader',
|
||||
testScale: 'Test accuracy',
|
||||
testReadTag: 'Read tag',
|
||||
systemFieldsRequired: 'Backend URL is required.',
|
||||
// Display tab
|
||||
brightness: 'Brightness',
|
||||
saved: 'Saved',
|
||||
|
||||
@@ -1067,8 +1067,6 @@ export function SettingsPage() {
|
||||
<span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* General Tab */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
|
||||
{/* Left Column - General Settings */}
|
||||
|
||||
@@ -15,6 +15,20 @@ const SPOOL_COLORS = [
|
||||
'#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
|
||||
];
|
||||
|
||||
function normalizeHexTag(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
|
||||
}
|
||||
|
||||
function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const aNorm = normalizeHexTag(a);
|
||||
const bNorm = normalizeHexTag(b);
|
||||
if (!aNorm || !bNorm) return false;
|
||||
if (aNorm === bNorm) return true;
|
||||
// Some readers report shortened UID forms.
|
||||
return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
|
||||
}
|
||||
|
||||
// --- Idle state with color-cycling spool and NFC waves ---
|
||||
function ColorCyclingSpool() {
|
||||
const { t } = useTranslation();
|
||||
@@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() {
|
||||
|
||||
// Find spool by tag_id in the loaded spools list
|
||||
const displayedSpool = useMemo(() => {
|
||||
if (sbState.matchedSpool?.id) {
|
||||
const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
|
||||
if (byId) return byId;
|
||||
}
|
||||
if (!displayedTagId) return null;
|
||||
return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
|
||||
}, [displayedTagId, spools]);
|
||||
return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;
|
||||
}, [displayedTagId, sbState.matchedSpool, spools]);
|
||||
|
||||
// Untagged spools for the Link feature
|
||||
const untaggedSpools = useMemo(() => {
|
||||
@@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
|
||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||
{!sbState.deviceOnline ? (
|
||||
<DeviceOfflineState />
|
||||
) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
|
||||
) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
|
||||
<SpoolInfoCard
|
||||
spool={{
|
||||
id: displayedSpool.id,
|
||||
id: displayedSpool?.id ?? sbState.matchedSpool!.id,
|
||||
tag_uid: displayedTagId,
|
||||
material: displayedSpool.material,
|
||||
subtype: displayedSpool.subtype,
|
||||
color_name: displayedSpool.color_name,
|
||||
rgba: displayedSpool.rgba,
|
||||
brand: displayedSpool.brand,
|
||||
label_weight: displayedSpool.label_weight,
|
||||
core_weight: displayedSpool.core_weight,
|
||||
weight_used: displayedSpool.weight_used,
|
||||
material: displayedSpool?.material ?? sbState.matchedSpool!.material,
|
||||
subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
|
||||
color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
|
||||
rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
|
||||
brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
|
||||
label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
|
||||
core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
|
||||
weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
|
||||
}}
|
||||
scaleWeight={liveWeight ?? displayedWeight}
|
||||
onSyncWeight={() => refetchSpools()}
|
||||
onAssignToAms={() => setShowAssignAmsModal(true)}
|
||||
onClose={handleCloseSpoolCard}
|
||||
/>
|
||||
) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
|
||||
) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
|
||||
<UnknownTagCard
|
||||
tagUid={displayedTagId}
|
||||
scaleWeight={liveWeight ?? displayedWeight}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
|
||||
import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
|
||||
import { FileText, Wand2, Zap } from 'lucide-react';
|
||||
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
@@ -36,6 +40,39 @@ const BLANK_OPTIONS = [
|
||||
|
||||
function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
|
||||
const { t } = useTranslation();
|
||||
const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);
|
||||
const [backendUrl, setBackendUrl] = useState('');
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
const [systemBusy, setSystemBusy] = useState(false);
|
||||
const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendUrl && device.backend_url) {
|
||||
setBackendUrl(device.backend_url);
|
||||
}
|
||||
}, [device.backend_url, backendUrl]);
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!backendUrl.trim()) {
|
||||
setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') });
|
||||
return;
|
||||
}
|
||||
|
||||
setSystemBusy(true);
|
||||
setSystemMsg(null);
|
||||
try {
|
||||
await spoolbuddyApi.updateSystemConfig(
|
||||
device.device_id,
|
||||
backendUrl.trim(),
|
||||
apiToken.trim() || undefined
|
||||
);
|
||||
setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });
|
||||
} catch (e) {
|
||||
setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
|
||||
} finally {
|
||||
setSystemBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -124,6 +161,116 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
|
||||
<span className="text-zinc-500">Device ID</span>
|
||||
<span className="text-zinc-400 font-mono">{device.device_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Backend/Auth Config */}
|
||||
<div className="bg-zinc-800 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">
|
||||
{t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-zinc-500 block">
|
||||
{t('spoolbuddy.settings.backendUrl', 'Bambuddy Backend URL')}
|
||||
</label>
|
||||
<input
|
||||
value={backendUrl}
|
||||
onChange={(e) => setBackendUrl(e.target.value)}
|
||||
placeholder="http://192.168.1.100:5000"
|
||||
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-zinc-500 block">
|
||||
{t('spoolbuddy.settings.apiToken', 'API Token')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'Enter API token')}
|
||||
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={systemBusy}
|
||||
className="px-3 py-2 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-sm font-medium text-zinc-100"
|
||||
>
|
||||
{t('spoolbuddy.settings.saveConfig', 'Save Config')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{systemMsg && (
|
||||
<div className={`text-xs ${systemMsg.type === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{systemMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Buttons */}
|
||||
<div className="bg-zinc-800 rounded-lg p-4 space-y-3">
|
||||
{/* NFC Diagnostic Button */}
|
||||
<button
|
||||
onClick={() => setDiagnosticOpen('nfc')}
|
||||
className="w-full bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Wand2 className="w-4 h-4 text-blue-300" />
|
||||
<span className="text-sm font-semibold text-blue-100">
|
||||
{t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-200/70">
|
||||
{t('spoolbuddy.settings.testNfc', 'Test reader')}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Scale Diagnostic Button */}
|
||||
<button
|
||||
onClick={() => setDiagnosticOpen('scale')}
|
||||
className="w-full bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-yellow-300" />
|
||||
<span className="text-sm font-semibold text-yellow-100">
|
||||
{t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-200/70">
|
||||
{t('spoolbuddy.settings.testScale', 'Test accuracy')}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Read Tag Diagnostic Button */}
|
||||
<button
|
||||
onClick={() => setDiagnosticOpen('read_tag')}
|
||||
className="w-full bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="w-4 h-4 text-emerald-300" />
|
||||
<span className="text-sm font-semibold text-emerald-100">
|
||||
{t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag Diagnostic')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-200/70">
|
||||
{t('spoolbuddy.settings.testReadTag', 'Run read_tag.py')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Modal */}
|
||||
{diagnosticOpen && device && (
|
||||
<DiagnosticModal
|
||||
type={diagnosticOpen}
|
||||
deviceId={device.device_id}
|
||||
onClose={() => setDiagnosticOpen(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,36 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
|
||||
import {
|
||||
api,
|
||||
spoolbuddyApi,
|
||||
type InventorySpool,
|
||||
type LocalPreset,
|
||||
type SlicerSetting,
|
||||
type SpoolCatalogEntry,
|
||||
} from '../../api/client';
|
||||
import { getCurrencySymbol } from '../../utils/currency';
|
||||
import { FilamentSection } from '../../components/spool-form/FilamentSection';
|
||||
import { ColorSection } from '../../components/spool-form/ColorSection';
|
||||
import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
|
||||
import { PAProfileSection } from '../../components/spool-form/PAProfileSection';
|
||||
import type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types';
|
||||
import { defaultFormData, validateForm } from '../../components/spool-form/types';
|
||||
import {
|
||||
buildFilamentOptions,
|
||||
extractBrandsFromPresets,
|
||||
findPresetOption,
|
||||
loadRecentColors,
|
||||
parsePresetName,
|
||||
saveRecentColor,
|
||||
} from '../../components/spool-form/utils';
|
||||
import { MATERIALS } from '../../components/spool-form/constants';
|
||||
|
||||
type Tab = 'existing' | 'new' | 'replace';
|
||||
type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
|
||||
|
||||
const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
|
||||
const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
|
||||
|
||||
export function SpoolBuddyWriteTagPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,16 +42,10 @@ export function SpoolBuddyWriteTagPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
|
||||
const [writeMessage, setWriteMessage] = useState('');
|
||||
const [untagging, setUntagging] = useState(false);
|
||||
const [tagOnReader, setTagOnReader] = useState(false);
|
||||
const [tagUid, setTagUid] = useState<string | null>(null);
|
||||
|
||||
// New spool form state
|
||||
const [newMaterial, setNewMaterial] = useState('PLA');
|
||||
const [newColorName, setNewColorName] = useState('');
|
||||
const [newColorHex, setNewColorHex] = useState('#00AE42');
|
||||
const [newBrand, setNewBrand] = useState('');
|
||||
const [newWeight, setNewWeight] = useState(1000);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [diagnosticOpen, setDiagnosticOpen] = useState<'scale' | 'nfc' | null>(null);
|
||||
|
||||
const { data: spools = [], refetch: refetchSpools } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
@@ -42,8 +59,14 @@ export function SpoolBuddyWriteTagPage() {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const device = devices[0];
|
||||
const deviceOnline = sbState.deviceOnline;
|
||||
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
|
||||
|
||||
// Filter spools based on tab
|
||||
const filteredSpools = useMemo(() => {
|
||||
@@ -51,7 +74,7 @@ export function SpoolBuddyWriteTagPage() {
|
||||
if (activeTab === 'existing') {
|
||||
list = spools.filter(s => !s.tag_uid && !s.archived_at);
|
||||
} else if (activeTab === 'replace') {
|
||||
list = spools.filter(s => s.tag_uid && !s.archived_at);
|
||||
list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -157,50 +180,60 @@ export function SpoolBuddyWriteTagPage() {
|
||||
setWriteMessage('');
|
||||
};
|
||||
|
||||
const handleCreateAndSelect = async () => {
|
||||
setCreating(true);
|
||||
const handleUntagSpool = async () => {
|
||||
if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;
|
||||
setUntagging(true);
|
||||
setWriteStatus('idle');
|
||||
setWriteMessage('');
|
||||
try {
|
||||
const rgba = newColorHex.replace('#', '') + 'FF';
|
||||
const spool = await api.createSpool({
|
||||
material: newMaterial,
|
||||
subtype: null,
|
||||
color_name: newColorName || null,
|
||||
rgba,
|
||||
brand: newBrand || null,
|
||||
label_weight: newWeight,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: true,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
await api.linkTagToSpool(selectedSpool.id, {
|
||||
tag_uid: '',
|
||||
tray_uuid: '',
|
||||
data_origin: 'manual',
|
||||
});
|
||||
setSelectedSpool(spool);
|
||||
refetchSpools();
|
||||
await refetchSpools();
|
||||
setSelectedSpool(null);
|
||||
setWriteStatus('success');
|
||||
setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool'));
|
||||
setTimeout(() => {
|
||||
setWriteStatus('idle');
|
||||
setWriteMessage('');
|
||||
}, 2500);
|
||||
} catch {
|
||||
setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
|
||||
setWriteStatus('error');
|
||||
setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
setUntagging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => {
|
||||
setSelectedSpool(createdSpool);
|
||||
setWriteStatus('idle');
|
||||
setWriteMessage('');
|
||||
void refetchSpools();
|
||||
}, [refetchSpools]);
|
||||
|
||||
const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
|
||||
|
||||
const handleOpenNfcDiagnostics = useCallback(() => {
|
||||
setDiagnosticOpen('nfc');
|
||||
}, []);
|
||||
|
||||
const handleOpenScaleDiagnostics = useCallback(() => {
|
||||
setDiagnosticOpen('scale');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{diagnosticOpen && (
|
||||
<DiagnosticModal
|
||||
type={diagnosticOpen}
|
||||
onClose={() => setDiagnosticOpen(null)}
|
||||
deviceId={device?.device_id || ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-bambu-dark-tertiary shrink-0">
|
||||
{([
|
||||
@@ -227,19 +260,9 @@ export function SpoolBuddyWriteTagPage() {
|
||||
{/* Left panel — spool list or form */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
|
||||
{activeTab === 'new' ? (
|
||||
<NewSpoolForm
|
||||
material={newMaterial}
|
||||
setMaterial={setNewMaterial}
|
||||
colorName={newColorName}
|
||||
setColorName={setNewColorName}
|
||||
colorHex={newColorHex}
|
||||
setColorHex={setNewColorHex}
|
||||
brand={newBrand}
|
||||
setBrand={setNewBrand}
|
||||
weight={newWeight}
|
||||
setWeight={setNewWeight}
|
||||
creating={creating}
|
||||
onSubmit={handleCreateAndSelect}
|
||||
<NewSpoolTouchForm
|
||||
currencySymbol={currencySymbol}
|
||||
onCreated={handleSpoolCreated}
|
||||
selectedSpool={selectedSpool}
|
||||
t={t}
|
||||
/>
|
||||
@@ -295,9 +318,14 @@ export function SpoolBuddyWriteTagPage() {
|
||||
deviceOnline={deviceOnline}
|
||||
canWrite={!!canWrite}
|
||||
isReplace={activeTab === 'replace'}
|
||||
canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}
|
||||
untagging={untagging}
|
||||
onWrite={handleWriteTag}
|
||||
onUntag={handleUntagSpool}
|
||||
onCancel={handleCancelWrite}
|
||||
onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
|
||||
onOpenNfcDiagnostics={handleOpenNfcDiagnostics}
|
||||
onOpenScaleDiagnostics={handleOpenScaleDiagnostics}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -306,6 +334,10 @@ export function SpoolBuddyWriteTagPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function isReplaceTagged(spool: InventorySpool): boolean {
|
||||
return !!(spool.tag_uid || spool.tray_uuid);
|
||||
}
|
||||
|
||||
// --- Spool list item ---
|
||||
function SpoolListItem({ spool, selected, showTag, onClick }: {
|
||||
spool: InventorySpool;
|
||||
@@ -358,118 +390,535 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
|
||||
);
|
||||
}
|
||||
|
||||
// --- New spool form ---
|
||||
function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: {
|
||||
material: string;
|
||||
setMaterial: (v: string) => void;
|
||||
colorName: string;
|
||||
setColorName: (v: string) => void;
|
||||
colorHex: string;
|
||||
setColorHex: (v: string) => void;
|
||||
brand: string;
|
||||
setBrand: (v: string) => void;
|
||||
weight: number;
|
||||
setWeight: (v: number) => void;
|
||||
creating: boolean;
|
||||
onSubmit: () => void;
|
||||
type NewSpoolSubTab = 'filament' | 'pa-profile';
|
||||
type NewSpoolViewMode = 'simple' | 'full';
|
||||
|
||||
// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) ---
|
||||
function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
|
||||
currencySymbol: string;
|
||||
onCreated: (spool: InventorySpool) => void;
|
||||
selectedSpool: InventorySpool | null;
|
||||
t: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
if (selectedSpool) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full mb-4 border border-white/10"
|
||||
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
|
||||
/>
|
||||
<p className="text-white font-medium">
|
||||
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
|
||||
</p>
|
||||
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
|
||||
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
|
||||
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
|
||||
const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
|
||||
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
|
||||
const [quickAdd, setQuickAdd] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
||||
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
|
||||
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
|
||||
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
|
||||
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
|
||||
const [colorCatalog, setColorCatalog] = useState<
|
||||
{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]
|
||||
>([]);
|
||||
const [presetInputValue, setPresetInputValue] = useState('');
|
||||
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
|
||||
|
||||
const [printersWithCalibrations, setPrintersWithCalibrations] = useState<PrinterWithCalibrations[]>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
|
||||
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setRecentColors(loadRecentColors());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Only load full data when in full view mode
|
||||
if (viewMode !== 'full') {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingCloudPresets(true);
|
||||
try {
|
||||
const status = await api.getCloudStatus();
|
||||
setCloudAuthenticated(status.is_authenticated);
|
||||
if (status.is_authenticated) {
|
||||
const presets = await api.getFilamentPresets();
|
||||
setCloudPresets(presets);
|
||||
}
|
||||
} catch {
|
||||
setCloudAuthenticated(false);
|
||||
} finally {
|
||||
setLoadingCloudPresets(false);
|
||||
}
|
||||
|
||||
api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
|
||||
api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
|
||||
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
|
||||
|
||||
try {
|
||||
const printers = await api.getPrinters();
|
||||
const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null)));
|
||||
const results: PrinterWithCalibrations[] = [];
|
||||
for (let i = 0; i < printers.length; i++) {
|
||||
const printer = printers[i];
|
||||
const status = statuses[i];
|
||||
const connected = status?.connected ?? false;
|
||||
let calibrations: PrinterWithCalibrations['calibrations'] = [];
|
||||
if (connected) {
|
||||
try {
|
||||
const kRes = await api.getKProfiles(printer.id);
|
||||
calibrations = kRes.profiles.map(p => ({
|
||||
cali_idx: p.slot_id,
|
||||
filament_id: p.filament_id,
|
||||
setting_id: p.setting_id || '',
|
||||
name: p.name,
|
||||
k_value: parseFloat(p.k_value) || 0,
|
||||
n_coef: parseFloat(p.n_coef) || 0,
|
||||
extruder_id: p.extruder_id,
|
||||
nozzle_diameter: p.nozzle_diameter,
|
||||
}));
|
||||
} catch {
|
||||
// ignore per-printer unsupported profile endpoints
|
||||
}
|
||||
}
|
||||
results.push({ printer: { ...printer, connected }, calibrations });
|
||||
}
|
||||
setPrintersWithCalibrations(results);
|
||||
} catch {
|
||||
// ignore calibration loading errors on kiosk form
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (printersWithCalibrations.length > 0) {
|
||||
setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id))));
|
||||
}
|
||||
}, [printersWithCalibrations]);
|
||||
|
||||
const filamentOptions = useMemo(
|
||||
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
|
||||
[cloudPresets, localPresets],
|
||||
);
|
||||
|
||||
const selectedPresetOption = useMemo(
|
||||
() => findPresetOption(formData.slicer_filament, filamentOptions),
|
||||
[formData.slicer_filament, filamentOptions],
|
||||
);
|
||||
|
||||
const baseAvailableBrands = useMemo(() => {
|
||||
const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
|
||||
const catalogBrands = colorCatalog
|
||||
.map(entry => entry.manufacturer?.trim())
|
||||
.filter((brand): brand is string => !!brand);
|
||||
return Array.from(new Set<string>([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b));
|
||||
}, [cloudPresets, localPresets, colorCatalog]);
|
||||
|
||||
const baseAvailableMaterials = useMemo(() => {
|
||||
const catalogMaterials = colorCatalog
|
||||
.map(entry => entry.material?.trim())
|
||||
.filter((material): material is string => !!material);
|
||||
return Array.from(new Set<string>([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b));
|
||||
}, [colorCatalog]);
|
||||
|
||||
const brandMaterialPairs = useMemo(() => {
|
||||
const pairs: Array<{ brand: string; material: string }> = [];
|
||||
for (const entry of colorCatalog) {
|
||||
const brand = entry.manufacturer?.trim();
|
||||
const material = entry.material?.trim();
|
||||
if (brand && material) pairs.push({ brand, material });
|
||||
}
|
||||
for (const preset of cloudPresets) {
|
||||
const parsed = parsePresetName(preset.name);
|
||||
if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material });
|
||||
}
|
||||
for (const preset of localPresets) {
|
||||
const parsed = parsePresetName(preset.name);
|
||||
const brand = preset.filament_vendor?.trim() || parsed.brand;
|
||||
const material = parsed.material;
|
||||
if (brand && material) pairs.push({ brand, material });
|
||||
}
|
||||
return pairs;
|
||||
}, [cloudPresets, colorCatalog, localPresets]);
|
||||
|
||||
const brandToMaterials = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const pair of brandMaterialPairs) {
|
||||
const brandKey = pair.brand.toLowerCase();
|
||||
const materialKey = pair.material.toLowerCase();
|
||||
if (!map.has(brandKey)) map.set(brandKey, new Set());
|
||||
map.get(brandKey)!.add(materialKey);
|
||||
}
|
||||
return map;
|
||||
}, [brandMaterialPairs]);
|
||||
|
||||
const materialToBrands = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const pair of brandMaterialPairs) {
|
||||
const brandKey = pair.brand.toLowerCase();
|
||||
const materialKey = pair.material.toLowerCase();
|
||||
if (!map.has(materialKey)) map.set(materialKey, new Set());
|
||||
map.get(materialKey)!.add(brandKey);
|
||||
}
|
||||
return map;
|
||||
}, [brandMaterialPairs]);
|
||||
|
||||
const availableBrands = useMemo(() => {
|
||||
if (!formData.material) return baseAvailableBrands;
|
||||
const materialKey = formData.material.toLowerCase();
|
||||
const brandKeys = materialToBrands.get(materialKey);
|
||||
if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
|
||||
return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
|
||||
}, [baseAvailableBrands, formData.material, materialToBrands]);
|
||||
|
||||
const availableMaterials = useMemo(() => {
|
||||
if (!formData.brand) return baseAvailableMaterials;
|
||||
const brandKey = formData.brand.toLowerCase();
|
||||
const materialKeys = brandToMaterials.get(brandKey);
|
||||
if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
|
||||
return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
|
||||
}, [baseAvailableMaterials, formData.brand, brandToMaterials]);
|
||||
|
||||
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
if (errors[key]) {
|
||||
setErrors(prev => ({ ...prev, [key]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorUsed = (color: ColorPreset) => {
|
||||
setRecentColors(prev => saveRecentColor(color, prev));
|
||||
};
|
||||
|
||||
const saveKProfiles = async (spoolId: number) => {
|
||||
if (selectedProfiles.size === 0) {
|
||||
try {
|
||||
await api.saveSpoolKProfiles(spoolId, []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles = [];
|
||||
for (const key of selectedProfiles) {
|
||||
const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
|
||||
const printerId = parseInt(printerIdStr);
|
||||
const caliIdx = parseInt(caliIdxStr);
|
||||
const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
|
||||
|
||||
const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
|
||||
if (pc) {
|
||||
const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
|
||||
if (cal) {
|
||||
profiles.push({
|
||||
printer_id: printerId,
|
||||
extruder,
|
||||
nozzle_diameter: cal.nozzle_diameter || '0.4',
|
||||
k_value: cal.k_value,
|
||||
name: cal.name || null,
|
||||
cali_idx: cal.cali_idx,
|
||||
setting_id: cal.setting_id || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profiles.length > 0) {
|
||||
await api.saveSpoolKProfiles(spoolId, profiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreateError(null);
|
||||
const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd);
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
setActiveSubTab('filament');
|
||||
return;
|
||||
}
|
||||
|
||||
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
|
||||
const payload = {
|
||||
material: formData.material,
|
||||
subtype: formData.subtype || null,
|
||||
brand: formData.brand || null,
|
||||
color_name: formData.color_name || null,
|
||||
rgba: formData.rgba || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
core_weight_catalog_id: formData.core_weight_catalog_id,
|
||||
weight_used: formData.weight_used,
|
||||
slicer_filament: formData.slicer_filament || null,
|
||||
slicer_filament_name: presetName,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: formData.note || null,
|
||||
cost_per_kg: formData.cost_per_kg,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
};
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
if (quantity > 1) {
|
||||
const created = await api.bulkCreateSpools(payload, quantity);
|
||||
for (const spool of created) {
|
||||
await saveKProfiles(spool.id);
|
||||
}
|
||||
if (created.length > 0) onCreated(created[0]);
|
||||
} else {
|
||||
const created = await api.createSpool(payload);
|
||||
await saveKProfiles(created.id);
|
||||
onCreated(created);
|
||||
}
|
||||
} catch {
|
||||
setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`;
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
{/* Material */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
|
||||
<select
|
||||
value={material}
|
||||
onChange={(e) => setMaterial(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
<div className="p-3 space-y-3 overflow-y-auto h-full">
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
|
||||
<span className="text-sm text-zinc-200">{t('spoolbuddy.writeTag.viewMode', 'View')}</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-bambu-dark-tertiary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('simple')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${
|
||||
viewMode === 'simple' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t('spoolbuddy.writeTag.simpleView', 'Simple')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('full')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${
|
||||
viewMode === 'full' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t('spoolbuddy.writeTag.fullView', 'Full')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'simple' ? (
|
||||
selectedSpool ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full mb-4 border border-white/10"
|
||||
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
|
||||
/>
|
||||
<p className="text-white font-medium">
|
||||
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
|
||||
</p>
|
||||
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
|
||||
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
|
||||
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
|
||||
<select
|
||||
value={formData.material}
|
||||
onChange={(e) => updateField('material', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
{SIMPLE_COMMON_MATERIALS.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color_name}
|
||||
onChange={(e) => updateField('color_name', e.target.value)}
|
||||
placeholder="Jade White"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={simpleColorHex}
|
||||
onChange={(e) => updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')}
|
||||
className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.brand}
|
||||
onChange={(e) => updateField('brand', e.target.value)}
|
||||
placeholder="Polymaker"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.label_weight}
|
||||
onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={10000}
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !formData.material}
|
||||
className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
|
||||
<span className="text-sm text-zinc-200">{t('inventory.quickAdd', 'Quick Add')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickAdd((prev) => !prev)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{COMMON_MATERIALS.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<span className={`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${quickAdd ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Color name + picker */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={colorName}
|
||||
onChange={(e) => setColorName(e.target.value)}
|
||||
placeholder="Jade White"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
|
||||
<div className="flex border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('filament')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium ${
|
||||
activeSubTab === 'filament' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t('inventory.filamentInfoTab', 'Filament')}
|
||||
</button>
|
||||
{!quickAdd && (
|
||||
<button
|
||||
onClick={() => setActiveSubTab('pa-profile')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium ${
|
||||
activeSubTab === 'pa-profile' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t('inventory.paProfileTab', 'PA Profile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3">
|
||||
{activeSubTab === 'filament' ? (
|
||||
<div className="space-y-4">
|
||||
<FilamentSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
cloudAuthenticated={cloudAuthenticated}
|
||||
loadingCloudPresets={loadingCloudPresets}
|
||||
presetInputValue={presetInputValue}
|
||||
setPresetInputValue={setPresetInputValue}
|
||||
selectedPresetOption={selectedPresetOption}
|
||||
filamentOptions={filamentOptions}
|
||||
availableBrands={availableBrands}
|
||||
availableMaterials={availableMaterials}
|
||||
quickAdd={quickAdd}
|
||||
quantity={quantity}
|
||||
onQuantityChange={setQuantity}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<ColorSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
recentColors={recentColors}
|
||||
onColorUsed={handleColorUsed}
|
||||
catalogColors={colorCatalog}
|
||||
/>
|
||||
|
||||
<AdditionalSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
spoolCatalog={spoolCatalog}
|
||||
currencySymbol={currencySymbol}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PAProfileSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
printersWithCalibrations={printersWithCalibrations}
|
||||
selectedProfiles={selectedProfiles}
|
||||
setSelectedProfiles={setSelectedProfiles}
|
||||
expandedPrinters={expandedPrinters}
|
||||
setExpandedPrinters={setExpandedPrinters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{createError && (
|
||||
<div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2">
|
||||
{createError}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={colorHex}
|
||||
onChange={(e) => setColorHex(e.target.value)}
|
||||
className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
|
||||
)}
|
||||
|
||||
{viewMode === 'full' && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{viewMode === 'full' && selectedSpool && (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full mb-4 border border-white/10"
|
||||
style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
|
||||
/>
|
||||
<p className="text-white font-medium">
|
||||
{selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
|
||||
</p>
|
||||
{selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
|
||||
<p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
|
||||
<p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="Polymaker"
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weight */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={10000}
|
||||
className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create button */}
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={creating || !material}
|
||||
className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{creating
|
||||
? t('spoolbuddy.writeTag.creating', 'Creating...')
|
||||
: t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- NFC status panel ---
|
||||
function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: {
|
||||
function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, onOpenNfcDiagnostics, onOpenScaleDiagnostics, t }: {
|
||||
writeStatus: WriteStatus;
|
||||
writeMessage: string;
|
||||
selectedSpool: InventorySpool | null;
|
||||
@@ -478,9 +927,14 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
|
||||
deviceOnline: boolean;
|
||||
canWrite: boolean;
|
||||
isReplace: boolean;
|
||||
canUntag: boolean;
|
||||
untagging: boolean;
|
||||
onWrite: () => void;
|
||||
onUntag: () => void;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onOpenNfcDiagnostics: () => void;
|
||||
onOpenScaleDiagnostics: () => void;
|
||||
t: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
// Success state
|
||||
@@ -630,6 +1084,34 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
|
||||
? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
|
||||
: t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
|
||||
</button>
|
||||
|
||||
{isReplace && canUntag && (
|
||||
<button
|
||||
onClick={onUntag}
|
||||
disabled={untagging}
|
||||
className="w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{untagging
|
||||
? t('spoolbuddy.writeTag.untagging', 'Removing tag...')
|
||||
: t('spoolbuddy.writeTag.untagSpool', 'Untag Selected Spool')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Diagnostic buttons */}
|
||||
<div className="w-full flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={onOpenNfcDiagnostics}
|
||||
className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
|
||||
>
|
||||
{t('spoolbuddy.writeTag.nfcDiagnostics', 'NFC Diag')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenScaleDiagnostics}
|
||||
className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
|
||||
>
|
||||
{t('spoolbuddy.writeTag.scaleDiagnostics', 'Scale Diag')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user