mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Removed new Spoolbuddy frontend
This commit is contained in:
@@ -19,12 +19,6 @@ import InventoryPage from './pages/InventoryPage';
|
||||
import { SystemInfoPage } from './pages/SystemInfoPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
|
||||
import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
|
||||
import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
|
||||
import { SpoolBuddyPrintersPage } from './pages/spoolbuddy/SpoolBuddyPrintersPage';
|
||||
import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
@@ -120,15 +114,6 @@ function App() {
|
||||
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
|
||||
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
|
||||
|
||||
{/* SpoolBuddy — standalone kiosk-optimized UI with its own layout */}
|
||||
<Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
|
||||
<Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
|
||||
<Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
|
||||
<Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
|
||||
<Route path="spoolbuddy/printers" element={<SpoolBuddyPrintersPage />} />
|
||||
<Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Main app with WebSocket for real-time updates */}
|
||||
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
|
||||
<Route index element={<PrintersPage />} />
|
||||
|
||||
@@ -4806,35 +4806,3 @@ export const supportApi = {
|
||||
clearLogs: () =>
|
||||
request<{ message: string }>('/support/logs', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const spoolBuddyApi = {
|
||||
getDevices: () =>
|
||||
request<unknown[]>('/spoolbuddy/devices'),
|
||||
|
||||
registerDevice: (data: Record<string, unknown>) =>
|
||||
request<unknown>('/spoolbuddy/devices/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
tare: (deviceId: string) =>
|
||||
request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
|
||||
setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number) =>
|
||||
request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc }),
|
||||
}),
|
||||
|
||||
getCalibration: (deviceId: string) =>
|
||||
request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
|
||||
|
||||
updateSpoolWeight: (spoolId: number, weightGrams: number) =>
|
||||
request<{ status: string; weight_used: number }>('/spoolbuddy/scale/update-spool-weight', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
interface AmsSlotCardProps {
|
||||
material: string | null;
|
||||
colorHex: string | null;
|
||||
colorName: string | null;
|
||||
remaining: number | null;
|
||||
isEmpty: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AmsSlotCard({ material, colorHex, remaining, isEmpty, onClick }: AmsSlotCardProps) {
|
||||
const color = colorHex ? `#${colorHex.substring(0, 6)}` : '#808080';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-[120px] h-[120px] rounded-xl border border-bambu-dark-tertiary bg-bg-secondary flex flex-col items-center justify-center gap-2 active:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className="text-text-muted text-[13px]">Empty</span>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-text-primary text-[13px] font-medium truncate max-w-[100px]">
|
||||
{material || '---'}
|
||||
</span>
|
||||
{remaining !== null && remaining >= 0 && (
|
||||
<div className="w-[80px] h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-bambu-green"
|
||||
style={{ width: `${Math.min(100, remaining)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../Button';
|
||||
import { Scale, Edit, Cpu, History } from 'lucide-react';
|
||||
|
||||
interface QuickActionGridProps {
|
||||
onUpdateWeight: () => void;
|
||||
onEditSpool: () => void;
|
||||
onAssignAms: () => void;
|
||||
onViewHistory: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionGrid({ onUpdateWeight, onEditSpool, onAssignAms, onViewHistory }: QuickActionGridProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions = [
|
||||
{ icon: Cpu, label: t('spoolbuddy.actions.assignAms'), onClick: onAssignAms, variant: 'primary' as const },
|
||||
{ icon: Scale, label: t('spoolbuddy.actions.updateWeight'), onClick: onUpdateWeight, variant: 'secondary' as const },
|
||||
{ icon: Edit, label: t('spoolbuddy.actions.editSpool'), onClick: onEditSpool, variant: 'secondary' as const },
|
||||
{ icon: History, label: t('spoolbuddy.actions.viewHistory'), onClick: onViewHistory, variant: 'secondary' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{actions.map(({ icon: Icon, label, onClick, variant }) => (
|
||||
<Button
|
||||
key={label}
|
||||
variant={variant}
|
||||
className="h-[64px] text-[14px] flex-col gap-1"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Outlet, useSearchParams } from 'react-router-dom';
|
||||
import { SpoolBuddyNav } from './SpoolBuddyNav';
|
||||
import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
|
||||
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
|
||||
|
||||
export function SpoolBuddyLayout() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const isKiosk = searchParams.get('kiosk') === '1' || window.innerHeight <= 600;
|
||||
const state = useSpoolBuddyState();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[1024px] h-[600px] mx-auto flex flex-col bg-bg-primary overflow-hidden"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<SpoolBuddyNav isKiosk={isKiosk} />
|
||||
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet context={state} />
|
||||
</main>
|
||||
|
||||
<SpoolBuddyStatusBar
|
||||
weightGrams={state.weight?.weight_grams ?? null}
|
||||
stable={state.weight?.stable ?? false}
|
||||
nfcOk={state.deviceOnline}
|
||||
deviceOnline={state.deviceOnline}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Scale, Cpu, Package, Printer, Settings, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/spoolbuddy', icon: Scale, labelKey: 'spoolbuddy.nav.dashboard', end: true },
|
||||
{ to: '/spoolbuddy/ams', icon: Cpu, labelKey: 'spoolbuddy.nav.ams' },
|
||||
{ to: '/spoolbuddy/inventory', icon: Package, labelKey: 'spoolbuddy.nav.inventory' },
|
||||
{ to: '/spoolbuddy/printers', icon: Printer, labelKey: 'spoolbuddy.nav.printers' },
|
||||
{ to: '/spoolbuddy/settings', icon: Settings, labelKey: 'spoolbuddy.nav.settings' },
|
||||
];
|
||||
|
||||
interface SpoolBuddyNavProps {
|
||||
isKiosk: boolean;
|
||||
}
|
||||
|
||||
export function SpoolBuddyNav({ isKiosk }: SpoolBuddyNavProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<nav className="h-[48px] bg-bg-secondary border-b border-bambu-dark-tertiary flex items-center px-2 gap-1">
|
||||
{!isKiosk && (
|
||||
<NavLink
|
||||
to="/"
|
||||
className="flex items-center gap-1 px-3 h-[40px] rounded-lg text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary text-[13px]"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-bambu-green font-bold text-[15px]">SpoolBuddy</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
{navItems.map(({ to, icon: Icon, labelKey, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1.5 px-4 h-[40px] rounded-lg text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Scale, Nfc } from 'lucide-react';
|
||||
|
||||
interface SpoolBuddyStatusBarProps {
|
||||
weightGrams: number | null;
|
||||
stable: boolean;
|
||||
nfcOk: boolean;
|
||||
deviceOnline: boolean;
|
||||
}
|
||||
|
||||
export function SpoolBuddyStatusBar({ weightGrams, stable, nfcOk, deviceOnline }: SpoolBuddyStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-[40px] bg-bg-secondary border-t border-bambu-dark-tertiary flex items-center px-4 text-[13px]">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale size={14} className="text-text-secondary" />
|
||||
<span className="text-text-primary font-mono">
|
||||
{weightGrams !== null ? `${weightGrams.toFixed(1)}g` : '---'}
|
||||
</span>
|
||||
{weightGrams !== null && (
|
||||
<span className={`w-2 h-2 rounded-full ${stable ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Nfc size={14} className="text-text-secondary" />
|
||||
<span className={nfcOk ? 'text-green-500' : 'text-text-muted'}>
|
||||
{nfcOk ? t('spoolbuddy.status.nfcReady') : t('spoolbuddy.status.nfcOff')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!deviceOnline && (
|
||||
<span className="text-red-400 text-[12px]">{t('spoolbuddy.status.offline')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-text-muted text-[12px]">SpoolBuddy</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SpoolInfo {
|
||||
id: number;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
weight_used: number;
|
||||
}
|
||||
|
||||
interface SpoolInfoCardProps {
|
||||
spool: SpoolInfo;
|
||||
}
|
||||
|
||||
export function SpoolInfoCard({ spool }: SpoolInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
|
||||
|
||||
// Convert RRGGBBAA to CSS color
|
||||
const color = spool.rgba
|
||||
? `#${spool.rgba.substring(0, 6)}`
|
||||
: '#808080';
|
||||
|
||||
const materialLabel = spool.subtype
|
||||
? `${spool.material} ${spool.subtype}`
|
||||
: spool.material;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[18px] font-semibold text-text-primary truncate">
|
||||
{materialLabel}
|
||||
</h3>
|
||||
<p className="text-[14px] text-text-secondary truncate">
|
||||
{[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[14px]">
|
||||
<span className="text-text-secondary">{t('spoolbuddy.spool.remaining')}</span>
|
||||
<span className="text-text-primary font-medium">{remaining}g ({pct}%)</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: pct > 20 ? 'var(--accent)' : pct > 5 ? '#f59e0b' : '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../Button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface UnknownTagCardProps {
|
||||
tagUid: string;
|
||||
sak?: number;
|
||||
tagType?: string;
|
||||
onLinkExisting: () => void;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
export function UnknownTagCard({ tagUid, sak, tagType, onLinkExisting, onCreateNew }: UnknownTagCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6">
|
||||
<AlertTriangle size={48} className="text-yellow-500 mb-4" />
|
||||
<h3 className="text-[24px] font-semibold text-text-primary mb-2">
|
||||
{t('spoolbuddy.tag.unknownTitle')}
|
||||
</h3>
|
||||
<p className="text-[14px] text-text-secondary mb-1 font-mono">{tagUid}</p>
|
||||
{tagType && (
|
||||
<p className="text-[13px] text-text-muted mb-6">
|
||||
{tagType}{sak !== undefined ? ` (SAK: 0x${sak.toString(16).toUpperCase().padStart(2, '0')})` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="w-full space-y-3 max-w-[320px]">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-[64px] text-[16px]"
|
||||
onClick={onLinkExisting}
|
||||
>
|
||||
{t('spoolbuddy.tag.linkExisting')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full h-[64px] text-[16px]"
|
||||
onClick={onCreateNew}
|
||||
>
|
||||
{t('spoolbuddy.tag.createNew')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface WeightDisplayProps {
|
||||
weightGrams: number | null;
|
||||
stable: boolean;
|
||||
rawAdc: number | null;
|
||||
onTare: () => void;
|
||||
onCalibrate: () => void;
|
||||
}
|
||||
|
||||
export function WeightDisplay({ weightGrams, stable, onTare, onCalibrate }: WeightDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4">
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className="text-[72px] font-bold text-text-primary leading-none"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{weightGrams !== null ? weightGrams.toFixed(1) : '---'}
|
||||
</span>
|
||||
<span className="text-[24px] text-text-secondary mt-1">g</span>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
weightGrams === null
|
||||
? 'bg-bambu-gray'
|
||||
: stable
|
||||
? 'bg-green-500'
|
||||
: 'bg-yellow-500 animate-pulse'
|
||||
}`} />
|
||||
<span className="text-[16px] text-text-secondary">
|
||||
{weightGrams === null
|
||||
? t('spoolbuddy.weight.noReading')
|
||||
: stable
|
||||
? t('spoolbuddy.weight.stable')
|
||||
: t('spoolbuddy.weight.measuring')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-3 pb-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1 h-[64px] text-[16px]"
|
||||
onClick={onTare}
|
||||
>
|
||||
{t('spoolbuddy.weight.tare')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1 h-[64px] text-[16px]"
|
||||
onClick={onCalibrate}
|
||||
>
|
||||
{t('spoolbuddy.weight.calibrate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useCallback, useEffect, useReducer } from 'react';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface SpoolInfo {
|
||||
id: number;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
weight_used: number;
|
||||
}
|
||||
|
||||
interface WeightData {
|
||||
weight_grams: number;
|
||||
stable: boolean;
|
||||
raw_adc: number | null;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
interface TagData {
|
||||
tag_uid: string;
|
||||
sak?: number;
|
||||
tag_type?: string;
|
||||
tray_uuid?: string;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
type DashboardView = 'idle' | 'tag_known' | 'tag_unknown';
|
||||
|
||||
interface SpoolBuddyState {
|
||||
view: DashboardView;
|
||||
weight: WeightData | null;
|
||||
tag: TagData | null;
|
||||
spool: SpoolInfo | null;
|
||||
deviceOnline: boolean;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'WEIGHT_UPDATE'; payload: WeightData }
|
||||
| { type: 'TAG_MATCHED'; payload: { tag: TagData; spool: SpoolInfo } }
|
||||
| { type: 'TAG_UNKNOWN'; payload: TagData }
|
||||
| { type: 'TAG_REMOVED' }
|
||||
| { type: 'DEVICE_ONLINE' }
|
||||
| { type: 'DEVICE_OFFLINE' };
|
||||
|
||||
// --- Reducer ---
|
||||
|
||||
const initialState: SpoolBuddyState = {
|
||||
view: 'idle',
|
||||
weight: null,
|
||||
tag: null,
|
||||
spool: null,
|
||||
deviceOnline: false,
|
||||
};
|
||||
|
||||
function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
|
||||
switch (action.type) {
|
||||
case 'WEIGHT_UPDATE':
|
||||
return { ...state, weight: action.payload };
|
||||
|
||||
case 'TAG_MATCHED':
|
||||
return {
|
||||
...state,
|
||||
view: 'tag_known',
|
||||
tag: action.payload.tag,
|
||||
spool: action.payload.spool,
|
||||
};
|
||||
|
||||
case 'TAG_UNKNOWN':
|
||||
return {
|
||||
...state,
|
||||
view: 'tag_unknown',
|
||||
tag: action.payload,
|
||||
spool: null,
|
||||
};
|
||||
|
||||
case 'TAG_REMOVED':
|
||||
return {
|
||||
...state,
|
||||
view: 'idle',
|
||||
tag: null,
|
||||
spool: null,
|
||||
};
|
||||
|
||||
case 'DEVICE_ONLINE':
|
||||
return { ...state, deviceOnline: true };
|
||||
|
||||
case 'DEVICE_OFFLINE':
|
||||
return { ...state, deviceOnline: false, weight: null };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
export function useSpoolBuddyState() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const handleWeight = useCallback((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
dispatch({
|
||||
type: 'WEIGHT_UPDATE',
|
||||
payload: {
|
||||
weight_grams: detail.weight_grams,
|
||||
stable: detail.stable,
|
||||
raw_adc: detail.raw_adc ?? null,
|
||||
device_id: detail.device_id,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTagMatched = useCallback((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
dispatch({
|
||||
type: 'TAG_MATCHED',
|
||||
payload: {
|
||||
tag: {
|
||||
tag_uid: detail.tag_uid,
|
||||
device_id: detail.device_id,
|
||||
},
|
||||
spool: detail.spool,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTagUnknown = useCallback((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
dispatch({
|
||||
type: 'TAG_UNKNOWN',
|
||||
payload: {
|
||||
tag_uid: detail.tag_uid,
|
||||
sak: detail.sak,
|
||||
tag_type: detail.tag_type,
|
||||
device_id: detail.device_id,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTagRemoved = useCallback(() => {
|
||||
dispatch({ type: 'TAG_REMOVED' });
|
||||
}, []);
|
||||
|
||||
const handleDeviceStatus = useCallback((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail.type === 'spoolbuddy_online') {
|
||||
dispatch({ type: 'DEVICE_ONLINE' });
|
||||
} else {
|
||||
dispatch({ type: 'DEVICE_OFFLINE' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('spoolbuddy-weight', handleWeight);
|
||||
window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
|
||||
window.addEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
|
||||
window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
|
||||
window.addEventListener('spoolbuddy-device-status', handleDeviceStatus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('spoolbuddy-weight', handleWeight);
|
||||
window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);
|
||||
window.removeEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
|
||||
window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
|
||||
window.removeEventListener('spoolbuddy-device-status', handleDeviceStatus);
|
||||
};
|
||||
}, [handleWeight, handleTagMatched, handleTagUnknown, handleTagRemoved, handleDeviceStatus]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -258,39 +258,6 @@ export function useWebSocket() {
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
case 'spoolbuddy_weight':
|
||||
window.dispatchEvent(new CustomEvent('spoolbuddy-weight', {
|
||||
detail: message as unknown as Record<string, unknown>,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'spoolbuddy_tag_matched':
|
||||
window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', {
|
||||
detail: message as unknown as Record<string, unknown>,
|
||||
}));
|
||||
debouncedInvalidate('inventory-spools');
|
||||
break;
|
||||
|
||||
case 'spoolbuddy_unknown_tag':
|
||||
window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', {
|
||||
detail: message as unknown as Record<string, unknown>,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'spoolbuddy_tag_removed':
|
||||
window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', {
|
||||
detail: message as unknown as Record<string, unknown>,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'spoolbuddy_online':
|
||||
case 'spoolbuddy_offline':
|
||||
window.dispatchEvent(new CustomEvent('spoolbuddy-device-status', {
|
||||
detail: message as unknown as Record<string, unknown>,
|
||||
}));
|
||||
debouncedInvalidate('spoolbuddy-devices');
|
||||
break;
|
||||
}
|
||||
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
|
||||
|
||||
|
||||
@@ -3525,69 +3525,4 @@ export default {
|
||||
daysAgo: 'vor {{count}}d',
|
||||
inDays: 'in {{count}}d',
|
||||
},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
ams: 'AMS',
|
||||
inventory: 'Inventar',
|
||||
printers: 'Drucker',
|
||||
settings: 'Einstellungen',
|
||||
},
|
||||
status: {
|
||||
nfcReady: 'Bereit',
|
||||
nfcOff: 'Aus',
|
||||
offline: 'Gerät offline',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'Spule auf die Waage legen und NFC-Tag scannen',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Kein Messwert',
|
||||
stable: 'Stabil',
|
||||
measuring: 'Messung',
|
||||
tare: 'Tara',
|
||||
calibrate: 'Kalibrieren',
|
||||
tareQueued: 'Tara-Befehl gesendet',
|
||||
},
|
||||
spool: {
|
||||
remaining: 'Verbleibend',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: 'Unbekannter Tag',
|
||||
linkExisting: 'Mit vorhandener Spule verknüpfen',
|
||||
createNew: 'Neue Spule erstellen',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'AMS zuweisen',
|
||||
updateWeight: 'Gewicht aktualisieren',
|
||||
editSpool: 'Spule bearbeiten',
|
||||
viewHistory: 'Verlauf anzeigen',
|
||||
weightUpdated: 'Spulengewicht aktualisiert',
|
||||
},
|
||||
ams: {
|
||||
noData: 'Keine AMS-Daten verfügbar',
|
||||
},
|
||||
inventory: {
|
||||
search: 'Spulen suchen...',
|
||||
empty: 'Keine Spulen gefunden',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'Keine Drucker konfiguriert',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: 'Waagenkalibrierung',
|
||||
currentWeight: 'Aktuelles Gewicht',
|
||||
tareOffset: 'Tara-Offset',
|
||||
knownWeight: 'Bekanntes Gewicht',
|
||||
calibrated: 'Kalibrierung aktualisiert',
|
||||
tareQueued: 'Tara-Befehl in Warteschlange',
|
||||
nfcReader: 'NFC-Leser',
|
||||
nfcConnected: 'Verbunden',
|
||||
nfcDisconnected: 'Getrennt',
|
||||
deviceInfo: 'Geräteinformation',
|
||||
deviceId: 'Geräte-ID',
|
||||
uptime: 'Betriebszeit',
|
||||
firmware: 'Firmware',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3530,69 +3530,4 @@ export default {
|
||||
daysAgo: '{{count}}d ago',
|
||||
inDays: 'in {{count}}d',
|
||||
},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
ams: 'AMS',
|
||||
inventory: 'Inventory',
|
||||
printers: 'Printers',
|
||||
settings: 'Settings',
|
||||
},
|
||||
status: {
|
||||
nfcReady: 'Ready',
|
||||
nfcOff: 'Off',
|
||||
offline: 'Device Offline',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'Place spool on scale and scan NFC tag',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'No reading',
|
||||
stable: 'Stable',
|
||||
measuring: 'Measuring',
|
||||
tare: 'Tare',
|
||||
calibrate: 'Calibrate',
|
||||
tareQueued: 'Tare command sent',
|
||||
},
|
||||
spool: {
|
||||
remaining: 'Remaining',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: 'Unknown Tag',
|
||||
linkExisting: 'Link to Existing Spool',
|
||||
createNew: 'Create New Spool',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'Assign AMS',
|
||||
updateWeight: 'Update Weight',
|
||||
editSpool: 'Edit Spool',
|
||||
viewHistory: 'View History',
|
||||
weightUpdated: 'Spool weight updated',
|
||||
},
|
||||
ams: {
|
||||
noData: 'No AMS data available',
|
||||
},
|
||||
inventory: {
|
||||
search: 'Search spools...',
|
||||
empty: 'No spools found',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'No printers configured',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: 'Scale Calibration',
|
||||
currentWeight: 'Current weight',
|
||||
tareOffset: 'Tare offset',
|
||||
knownWeight: 'Known weight',
|
||||
calibrated: 'Calibration updated',
|
||||
tareQueued: 'Tare command queued',
|
||||
nfcReader: 'NFC Reader',
|
||||
nfcConnected: 'Connected',
|
||||
nfcDisconnected: 'Disconnected',
|
||||
deviceInfo: 'Device Info',
|
||||
deviceId: 'Device ID',
|
||||
uptime: 'Uptime',
|
||||
firmware: 'Firmware',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3493,69 +3493,4 @@ export default {
|
||||
daysAgo: 'il y a {{count}}j',
|
||||
inDays: 'dans {{count}}j',
|
||||
},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'Tableau de bord',
|
||||
ams: 'AMS',
|
||||
inventory: 'Inventaire',
|
||||
printers: 'Imprimantes',
|
||||
settings: 'Paramètres',
|
||||
},
|
||||
status: {
|
||||
nfcReady: 'Prêt',
|
||||
nfcOff: 'Désactivé',
|
||||
offline: 'Appareil hors ligne',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'Placez la bobine sur la balance et scannez le tag NFC',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Aucune mesure',
|
||||
stable: 'Stable',
|
||||
measuring: 'Mesure en cours',
|
||||
tare: 'Tare',
|
||||
calibrate: 'Calibrer',
|
||||
tareQueued: 'Commande de tare envoyée',
|
||||
},
|
||||
spool: {
|
||||
remaining: 'Restant',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: 'Tag inconnu',
|
||||
linkExisting: 'Lier à une bobine existante',
|
||||
createNew: 'Créer une nouvelle bobine',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'Assigner AMS',
|
||||
updateWeight: 'Mettre à jour le poids',
|
||||
editSpool: 'Modifier la bobine',
|
||||
viewHistory: 'Voir l\'historique',
|
||||
weightUpdated: 'Poids de la bobine mis à jour',
|
||||
},
|
||||
ams: {
|
||||
noData: 'Aucune donnée AMS disponible',
|
||||
},
|
||||
inventory: {
|
||||
search: 'Rechercher des bobines...',
|
||||
empty: 'Aucune bobine trouvée',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'Aucune imprimante configurée',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: 'Calibration de la balance',
|
||||
currentWeight: 'Poids actuel',
|
||||
tareOffset: 'Offset de tare',
|
||||
knownWeight: 'Poids connu',
|
||||
calibrated: 'Calibration mise à jour',
|
||||
tareQueued: 'Commande de tare en file d\'attente',
|
||||
nfcReader: 'Lecteur NFC',
|
||||
nfcConnected: 'Connecté',
|
||||
nfcDisconnected: 'Déconnecté',
|
||||
deviceInfo: 'Info appareil',
|
||||
deviceId: 'ID appareil',
|
||||
uptime: 'Temps de fonctionnement',
|
||||
firmware: 'Firmware',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2881,69 +2881,4 @@ export default {
|
||||
daysAgo: '{{count}}g fa',
|
||||
inDays: 'tra {{count}}g',
|
||||
},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
ams: 'AMS',
|
||||
inventory: 'Inventario',
|
||||
printers: 'Stampanti',
|
||||
settings: 'Impostazioni',
|
||||
},
|
||||
status: {
|
||||
nfcReady: 'Pronto',
|
||||
nfcOff: 'Spento',
|
||||
offline: 'Dispositivo offline',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'Posiziona la bobina sulla bilancia e scansiona il tag NFC',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Nessuna lettura',
|
||||
stable: 'Stabile',
|
||||
measuring: 'Misurazione',
|
||||
tare: 'Tara',
|
||||
calibrate: 'Calibra',
|
||||
tareQueued: 'Comando tara inviato',
|
||||
},
|
||||
spool: {
|
||||
remaining: 'Rimanente',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: 'Tag sconosciuto',
|
||||
linkExisting: 'Collega a bobina esistente',
|
||||
createNew: 'Crea nuova bobina',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'Assegna AMS',
|
||||
updateWeight: 'Aggiorna peso',
|
||||
editSpool: 'Modifica bobina',
|
||||
viewHistory: 'Visualizza cronologia',
|
||||
weightUpdated: 'Peso bobina aggiornato',
|
||||
},
|
||||
ams: {
|
||||
noData: 'Nessun dato AMS disponibile',
|
||||
},
|
||||
inventory: {
|
||||
search: 'Cerca bobine...',
|
||||
empty: 'Nessuna bobina trovata',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'Nessuna stampante configurata',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: 'Calibrazione bilancia',
|
||||
currentWeight: 'Peso attuale',
|
||||
tareOffset: 'Offset tara',
|
||||
knownWeight: 'Peso noto',
|
||||
calibrated: 'Calibrazione aggiornata',
|
||||
tareQueued: 'Comando tara in coda',
|
||||
nfcReader: 'Lettore NFC',
|
||||
nfcConnected: 'Connesso',
|
||||
nfcDisconnected: 'Disconnesso',
|
||||
deviceInfo: 'Info dispositivo',
|
||||
deviceId: 'ID dispositivo',
|
||||
uptime: 'Tempo di attività',
|
||||
firmware: 'Firmware',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3359,69 +3359,4 @@ export default {
|
||||
daysAgo: '{{count}}日前',
|
||||
inDays: 'あと{{count}}日',
|
||||
},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'ダッシュボード',
|
||||
ams: 'AMS',
|
||||
inventory: '在庫',
|
||||
printers: 'プリンター',
|
||||
settings: '設定',
|
||||
},
|
||||
status: {
|
||||
nfcReady: '準備完了',
|
||||
nfcOff: 'オフ',
|
||||
offline: 'デバイスオフライン',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'スプールを秤に置き、NFCタグをスキャンしてください',
|
||||
},
|
||||
weight: {
|
||||
noReading: '計測なし',
|
||||
stable: '安定',
|
||||
measuring: '計測中',
|
||||
tare: '風袋',
|
||||
calibrate: '校正',
|
||||
tareQueued: '風袋コマンドを送信しました',
|
||||
},
|
||||
spool: {
|
||||
remaining: '残量',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: '不明なタグ',
|
||||
linkExisting: '既存のスプールにリンク',
|
||||
createNew: '新しいスプールを作成',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'AMS割り当て',
|
||||
updateWeight: '重量更新',
|
||||
editSpool: 'スプール編集',
|
||||
viewHistory: '履歴表示',
|
||||
weightUpdated: 'スプール重量を更新しました',
|
||||
},
|
||||
ams: {
|
||||
noData: 'AMSデータがありません',
|
||||
},
|
||||
inventory: {
|
||||
search: 'スプールを検索...',
|
||||
empty: 'スプールが見つかりません',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'プリンターが設定されていません',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: '秤の校正',
|
||||
currentWeight: '現在の重量',
|
||||
tareOffset: '風袋オフセット',
|
||||
knownWeight: '既知の重量',
|
||||
calibrated: '校正が更新されました',
|
||||
tareQueued: '風袋コマンドをキューに追加しました',
|
||||
nfcReader: 'NFCリーダー',
|
||||
nfcConnected: '接続済み',
|
||||
nfcDisconnected: '未接続',
|
||||
deviceInfo: 'デバイス情報',
|
||||
deviceId: 'デバイスID',
|
||||
uptime: '稼働時間',
|
||||
firmware: 'ファームウェア',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3491,69 +3491,4 @@ export default {
|
||||
|
||||
// Spoolman Settings
|
||||
spoolmanSettings: {},
|
||||
spoolbuddy: {
|
||||
nav: {
|
||||
dashboard: 'Painel',
|
||||
ams: 'AMS',
|
||||
inventory: 'Inventário',
|
||||
printers: 'Impressoras',
|
||||
settings: 'Configurações',
|
||||
},
|
||||
status: {
|
||||
nfcReady: 'Pronto',
|
||||
nfcOff: 'Desligado',
|
||||
offline: 'Dispositivo offline',
|
||||
},
|
||||
dashboard: {
|
||||
idleMessage: 'Coloque o carretel na balança e escaneie a tag NFC',
|
||||
},
|
||||
weight: {
|
||||
noReading: 'Sem leitura',
|
||||
stable: 'Estável',
|
||||
measuring: 'Medindo',
|
||||
tare: 'Tara',
|
||||
calibrate: 'Calibrar',
|
||||
tareQueued: 'Comando de tara enviado',
|
||||
},
|
||||
spool: {
|
||||
remaining: 'Restante',
|
||||
},
|
||||
tag: {
|
||||
unknownTitle: 'Tag desconhecida',
|
||||
linkExisting: 'Vincular a carretel existente',
|
||||
createNew: 'Criar novo carretel',
|
||||
},
|
||||
actions: {
|
||||
assignAms: 'Atribuir AMS',
|
||||
updateWeight: 'Atualizar peso',
|
||||
editSpool: 'Editar carretel',
|
||||
viewHistory: 'Ver histórico',
|
||||
weightUpdated: 'Peso do carretel atualizado',
|
||||
},
|
||||
ams: {
|
||||
noData: 'Nenhum dado AMS disponível',
|
||||
},
|
||||
inventory: {
|
||||
search: 'Buscar carretéis...',
|
||||
empty: 'Nenhum carretel encontrado',
|
||||
},
|
||||
printers: {
|
||||
noPrinters: 'Nenhuma impressora configurada',
|
||||
},
|
||||
settings: {
|
||||
scaleCalibration: 'Calibração da balança',
|
||||
currentWeight: 'Peso atual',
|
||||
tareOffset: 'Offset de tara',
|
||||
knownWeight: 'Peso conhecido',
|
||||
calibrated: 'Calibração atualizada',
|
||||
tareQueued: 'Comando de tara na fila',
|
||||
nfcReader: 'Leitor NFC',
|
||||
nfcConnected: 'Conectado',
|
||||
nfcDisconnected: 'Desconectado',
|
||||
deviceInfo: 'Info do dispositivo',
|
||||
deviceId: 'ID do dispositivo',
|
||||
uptime: 'Tempo ativo',
|
||||
firmware: 'Firmware',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmsSlotCard } from '../../components/spoolbuddy/AmsSlotCard';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface PrinterOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function SpoolBuddyAmsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const printerList = (printers || []) as PrinterOption[];
|
||||
const activePrinterId = selectedPrinterId ?? printerList[0]?.id ?? null;
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', activePrinterId],
|
||||
enabled: activePrinterId !== null,
|
||||
});
|
||||
|
||||
const amsData = (status as Record<string, unknown>)?.ams as Record<string, unknown> | undefined;
|
||||
const amsUnits = amsData?.ams as Array<Record<string, unknown>> | undefined;
|
||||
return (
|
||||
<div className="h-[512px] p-4 overflow-y-auto">
|
||||
{/* Printer selector */}
|
||||
<div className="mb-4">
|
||||
<select
|
||||
value={activePrinterId ?? ''}
|
||||
onChange={(e) => setSelectedPrinterId(Number(e.target.value))}
|
||||
className="h-[48px] w-full bg-bg-secondary border border-bambu-dark-tertiary rounded-lg px-4 text-text-primary text-[14px]"
|
||||
>
|
||||
{printerList.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* AMS units */}
|
||||
{amsUnits ? (
|
||||
<div className="space-y-6">
|
||||
{amsUnits.map((ams, amsIdx) => {
|
||||
const trays = ams.tray as Array<Record<string, unknown>> | undefined;
|
||||
if (!trays) return null;
|
||||
const label = String.fromCharCode(65 + amsIdx); // A, B, C, D
|
||||
|
||||
return (
|
||||
<div key={amsIdx}>
|
||||
<h3 className="text-[16px] font-semibold text-text-primary mb-2">AMS-{label}</h3>
|
||||
<div className="flex gap-2">
|
||||
{trays.map((tray, trayIdx) => {
|
||||
const trayType = tray.tray_type as string | undefined;
|
||||
const trayColor = tray.tray_color as string | undefined;
|
||||
const remain = tray.remain as number | undefined;
|
||||
const isEmpty = !trayType || trayType === '';
|
||||
|
||||
return (
|
||||
<AmsSlotCard
|
||||
key={trayIdx}
|
||||
material={trayType || null}
|
||||
colorHex={trayColor || null}
|
||||
colorName={null}
|
||||
remaining={remain ?? null}
|
||||
isEmpty={isEmpty}
|
||||
onClick={() => {/* TODO: slot detail modal */}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-text-muted text-[16px]">{t('spoolbuddy.ams.noData')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Scale, Nfc } from 'lucide-react';
|
||||
import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
|
||||
import { SpoolInfoCard } from '../../components/spoolbuddy/SpoolInfoCard';
|
||||
import { UnknownTagCard } from '../../components/spoolbuddy/UnknownTagCard';
|
||||
import { QuickActionGrid } from '../../components/spoolbuddy/QuickActionGrid';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { spoolBuddyApi } from '../../api/client';
|
||||
|
||||
interface SpoolBuddyState {
|
||||
view: 'idle' | 'tag_known' | 'tag_unknown';
|
||||
weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
|
||||
tag: { tag_uid: string; sak?: number; tag_type?: string; device_id: string } | null;
|
||||
spool: {
|
||||
id: number;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
weight_used: number;
|
||||
} | null;
|
||||
deviceOnline: boolean;
|
||||
}
|
||||
|
||||
export function SpoolBuddyDashboard() {
|
||||
const state = useOutletContext<SpoolBuddyState>();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const updateWeightMutation = useMutation({
|
||||
mutationFn: (data: { spool_id: number; weight_grams: number }) =>
|
||||
spoolBuddyApi.updateSpoolWeight(data.spool_id, data.weight_grams),
|
||||
onSuccess: () => showToast(t('spoolbuddy.actions.weightUpdated'), 'success'),
|
||||
onError: () => showToast(t('common.error'), 'error'),
|
||||
});
|
||||
|
||||
const handleUpdateWeight = () => {
|
||||
if (state.spool && state.weight) {
|
||||
updateWeightMutation.mutate({
|
||||
spool_id: state.spool.id,
|
||||
weight_grams: state.weight.weight_grams,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTare = async () => {
|
||||
if (state.weight?.device_id) {
|
||||
try {
|
||||
await spoolBuddyApi.tare(state.weight.device_id);
|
||||
showToast(t('spoolbuddy.weight.tareQueued'), 'success');
|
||||
} catch {
|
||||
showToast(t('common.error'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[512px]">
|
||||
{/* Left panel — Weight */}
|
||||
<div className="w-[512px] border-r border-bambu-dark-tertiary">
|
||||
<WeightDisplay
|
||||
weightGrams={state.weight?.weight_grams ?? null}
|
||||
stable={state.weight?.stable ?? false}
|
||||
rawAdc={state.weight?.raw_adc ?? null}
|
||||
onTare={handleTare}
|
||||
onCalibrate={() => {/* TODO: open calibration modal */}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel — Tag state */}
|
||||
<div className="w-[512px] p-6 overflow-y-auto">
|
||||
{state.view === 'idle' && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="flex gap-4 mb-6">
|
||||
<Scale size={48} className="text-text-muted" />
|
||||
<Nfc size={48} className="text-text-muted" />
|
||||
</div>
|
||||
<p className="text-[24px] text-text-secondary max-w-[360px]">
|
||||
{t('spoolbuddy.dashboard.idleMessage')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.view === 'tag_known' && state.spool && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1">
|
||||
<SpoolInfoCard spool={state.spool} />
|
||||
</div>
|
||||
<QuickActionGrid
|
||||
onUpdateWeight={handleUpdateWeight}
|
||||
onEditSpool={() => {/* TODO: open spool form modal */}}
|
||||
onAssignAms={() => {/* TODO: open assign modal */}}
|
||||
onViewHistory={() => {/* TODO: navigate to history */}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.view === 'tag_unknown' && state.tag && (
|
||||
<UnknownTagCard
|
||||
tagUid={state.tag.tag_uid}
|
||||
sak={state.tag.sak}
|
||||
tagType={state.tag.tag_type}
|
||||
onLinkExisting={() => {/* TODO: open link modal */}}
|
||||
onCreateNew={() => {/* TODO: open create modal */}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Plus } from 'lucide-react';
|
||||
import { Button } from '../../components/Button';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface InventorySpool {
|
||||
id: number;
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
weight_used: number;
|
||||
}
|
||||
|
||||
export function SpoolBuddyInventoryPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: spools, isLoading } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(),
|
||||
});
|
||||
|
||||
const spoolList = (spools || []) as InventorySpool[];
|
||||
const filtered = spoolList.filter((s) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
s.material.toLowerCase().includes(q) ||
|
||||
(s.brand?.toLowerCase().includes(q)) ||
|
||||
(s.color_name?.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-[512px] flex flex-col p-4">
|
||||
{/* Search + Add */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('spoolbuddy.inventory.search')}
|
||||
className="w-full h-[48px] bg-bg-secondary border border-bambu-dark-tertiary rounded-lg pl-10 pr-4 text-text-primary text-[14px] placeholder:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" className="h-[48px] px-4">
|
||||
<Plus size={18} />
|
||||
<span className="ml-1">{t('common.add')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Spool grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filtered.map((spool) => {
|
||||
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
|
||||
const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
|
||||
const color = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
|
||||
const materialLabel = spool.subtype ? `${spool.material} ${spool.subtype}` : spool.material;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={spool.id}
|
||||
className="flex items-center gap-3 p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary active:bg-bambu-dark-tertiary transition-colors text-left"
|
||||
>
|
||||
<div
|
||||
className="w-[32px] h-[32px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold text-text-primary truncate">{materialLabel}</p>
|
||||
<p className="text-[12px] text-text-secondary truncate">
|
||||
{[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[12px] text-text-muted">{remaining}g</span>
|
||||
<div className="flex-1 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-bambu-green"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && !isLoading && (
|
||||
<div className="flex items-center justify-center h-[200px]">
|
||||
<p className="text-text-muted text-[14px]">{t('spoolbuddy.inventory.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface PrinterInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
model: string;
|
||||
ip_address: string;
|
||||
}
|
||||
|
||||
export function SpoolBuddyPrintersPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const printerList = (printers || []) as PrinterInfo[];
|
||||
|
||||
return (
|
||||
<div className="h-[512px] p-4 overflow-y-auto space-y-2">
|
||||
{printerList.map((printer) => {
|
||||
return (
|
||||
<PrinterCard key={printer.id} printer={printer} />
|
||||
);
|
||||
})}
|
||||
|
||||
{printerList.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-text-muted text-[16px]">{t('spoolbuddy.printers.noPrinters')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrinterCard({ printer }: { printer: PrinterInfo }) {
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
});
|
||||
|
||||
const st = status as Record<string, unknown> | undefined;
|
||||
const isOnline = st?.online === true;
|
||||
const printPct = st?.mc_percent as number | undefined;
|
||||
const nozzleTemp = st?.nozzle_temper as number | undefined;
|
||||
const bedTemp = st?.bed_temper as number | undefined;
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-bambu-gray'}`} />
|
||||
<h3 className="text-[16px] font-semibold text-text-primary">{printer.name}</h3>
|
||||
</div>
|
||||
<span className={`text-[13px] px-2 py-0.5 rounded ${isOnline ? 'bg-green-500/20 text-green-400' : 'bg-bambu-dark-tertiary text-text-muted'}`}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-[13px] text-text-secondary">
|
||||
<span>{printer.model}</span>
|
||||
<span>{printer.ip_address}</span>
|
||||
{printPct !== undefined && printPct > 0 && (
|
||||
<span>Print: {printPct}%</span>
|
||||
)}
|
||||
{nozzleTemp !== undefined && bedTemp !== undefined && isOnline && (
|
||||
<span>{Math.round(nozzleTemp)}° / {Math.round(bedTemp)}°</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/Button';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { spoolBuddyApi } from '../../api/client';
|
||||
|
||||
interface SpoolBuddyState {
|
||||
weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
|
||||
deviceOnline: boolean;
|
||||
}
|
||||
|
||||
interface DeviceInfo {
|
||||
device_id: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
firmware_version: string | null;
|
||||
tare_offset: number;
|
||||
calibration_factor: number;
|
||||
nfc_ok: boolean;
|
||||
scale_ok: boolean;
|
||||
uptime_s: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export function SpoolBuddySettingsPage() {
|
||||
const state = useOutletContext<SpoolBuddyState>();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [knownWeight, setKnownWeight] = useState('500');
|
||||
|
||||
const { data: devices } = useQuery({
|
||||
queryKey: ['spoolbuddy-devices'],
|
||||
queryFn: spoolBuddyApi.getDevices,
|
||||
});
|
||||
|
||||
const deviceList = (devices || []) as DeviceInfo[];
|
||||
const device = deviceList[0]; // Primary device
|
||||
const deviceId = device?.device_id ?? state.weight?.device_id;
|
||||
|
||||
const tareMutation = useMutation({
|
||||
mutationFn: () => spoolBuddyApi.tare(deviceId!),
|
||||
onSuccess: () => {
|
||||
showToast(t('spoolbuddy.settings.tareQueued'), 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
|
||||
},
|
||||
onError: () => showToast(t('common.error'), 'error'),
|
||||
});
|
||||
|
||||
const calibrateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
spoolBuddyApi.setCalibrationFactor(deviceId!, parseFloat(knownWeight), state.weight?.raw_adc ?? 0),
|
||||
onSuccess: () => {
|
||||
showToast(t('spoolbuddy.settings.calibrated'), 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
|
||||
},
|
||||
onError: () => showToast(t('common.error'), 'error'),
|
||||
});
|
||||
|
||||
const formatUptime = (s: number) => {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[512px] p-4 overflow-y-auto space-y-4">
|
||||
{/* Scale Calibration */}
|
||||
<section>
|
||||
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
|
||||
{t('spoolbuddy.settings.scaleCalibration')}
|
||||
</h2>
|
||||
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 space-y-4">
|
||||
<div className="flex justify-between text-[14px]">
|
||||
<span className="text-text-secondary">{t('spoolbuddy.settings.currentWeight')}</span>
|
||||
<span className="text-text-primary font-mono">
|
||||
{state.weight ? `${state.weight.weight_grams.toFixed(1)}g (raw: ${state.weight.raw_adc ?? '---'})` : '---'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[14px] text-text-secondary">
|
||||
{t('spoolbuddy.settings.tareOffset')}: {device?.tare_offset ?? '---'}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-[48px] px-6"
|
||||
onClick={() => tareMutation.mutate()}
|
||||
disabled={!deviceId || tareMutation.isPending}
|
||||
>
|
||||
{t('spoolbuddy.weight.tare')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] text-text-secondary whitespace-nowrap">
|
||||
{t('spoolbuddy.settings.knownWeight')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={knownWeight}
|
||||
onChange={(e) => setKnownWeight(e.target.value)}
|
||||
className="h-[48px] w-[120px] bg-bg-primary border border-bambu-dark-tertiary rounded-lg px-3 text-text-primary text-[14px] text-right"
|
||||
/>
|
||||
<span className="text-[14px] text-text-secondary">g</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-[48px] px-6 ml-auto"
|
||||
onClick={() => calibrateMutation.mutate()}
|
||||
disabled={!deviceId || !state.weight?.raw_adc || calibrateMutation.isPending}
|
||||
>
|
||||
{t('spoolbuddy.weight.calibrate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* NFC Reader */}
|
||||
<section>
|
||||
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
|
||||
{t('spoolbuddy.settings.nfcReader')}
|
||||
</h2>
|
||||
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${device?.nfc_ok ? 'bg-green-500' : 'bg-bambu-gray'}`} />
|
||||
<span className="text-[14px] text-text-primary">
|
||||
{device?.nfc_ok ? t('spoolbuddy.settings.nfcConnected') : t('spoolbuddy.settings.nfcDisconnected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Device Info */}
|
||||
{device && (
|
||||
<section>
|
||||
<h2 className="text-[18px] font-semibold text-text-primary mb-3">
|
||||
{t('spoolbuddy.settings.deviceInfo')}
|
||||
</h2>
|
||||
<div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 text-[14px] space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">{t('spoolbuddy.settings.deviceId')}</span>
|
||||
<span className="text-text-primary font-mono">{device.device_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">IP</span>
|
||||
<span className="text-text-primary">{device.ip_address}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">{t('spoolbuddy.settings.uptime')}</span>
|
||||
<span className="text-text-primary">{formatUptime(device.uptime_s)}</span>
|
||||
</div>
|
||||
{device.firmware_version && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">{t('spoolbuddy.settings.firmware')}</span>
|
||||
<span className="text-text-primary">{device.firmware_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user