This commit is contained in:
maziggy
2026-02-12 07:07:50 +01:00
parent 778d1966af
commit ec82092bc7
51 changed files with 16968 additions and 9173 deletions
+2
View File
@@ -14,6 +14,7 @@ import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import InventoryPage from './pages/InventoryPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
@@ -122,6 +123,7 @@ function App() {
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
<Route path="inventory" element={<InventoryPage />} />
<Route path="files" element={<FileManagerPage />} />
<Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
<Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
+182 -1
View File
@@ -812,6 +812,29 @@ export interface SlicerSetting {
version: string | null;
user_id: string | null;
updated_time: string | null;
is_custom: boolean;
}
export interface SpoolCatalogEntry {
id: number;
name: string;
weight: number;
is_default: boolean;
}
export interface ColorCatalogEntry {
id: number;
manufacturer: string;
color_name: string;
hex_color: string;
material: string | null;
is_default: boolean;
}
export interface ColorLookupResult {
found: boolean;
hex_color: string | null;
material: string | null;
}
export interface SlicerSettingsResponse {
@@ -853,6 +876,12 @@ export interface SlicerSettingDeleteResponse {
message: string;
}
// Built-in filament fallback (static table from backend)
export interface BuiltinFilament {
filament_id: string;
name: string;
}
// Local preset types (OrcaSlicer imports)
export interface LocalPreset {
id: number;
@@ -1686,6 +1715,84 @@ export interface LinkedSpoolsMap {
linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
}
// Inventory types
export interface InventorySpool {
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;
slicer_filament: string | null;
slicer_filament_name: string | null;
nozzle_temp_min: number | null;
nozzle_temp_max: number | null;
note: string | null;
added_full: boolean | null;
last_used: string | null;
encode_time: string | null;
tag_uid: string | null;
tray_uuid: string | null;
data_origin: string | null;
tag_type: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
k_profiles?: SpoolKProfile[];
}
export interface SpoolUsageRecord {
id: number;
spool_id: number;
printer_id: number | null;
print_name: string | null;
weight_used: number;
percent_used: number;
status: string;
created_at: string;
}
export interface SpoolKProfile {
id: number;
spool_id: number;
printer_id: number;
extruder: number;
nozzle_diameter: string;
nozzle_type: string | null;
k_value: number;
name: string | null;
cali_idx: number | null;
setting_id: string | null;
created_at: string;
}
export interface SpoolKProfileInput {
printer_id: number;
extruder?: number;
nozzle_diameter?: string;
nozzle_type?: string | null;
k_value: number;
name?: string | null;
cali_idx?: number | null;
setting_id?: string | null;
}
export interface SpoolAssignment {
id: number;
spool_id: number;
printer_id: number;
ams_id: number;
tray_id: number;
fingerprint_color: string | null;
fingerprint_type: string | null;
spool?: InventorySpool | null;
configured: boolean;
created_at: string;
}
// Update types
export interface VersionInfo {
version: string;
@@ -2019,7 +2126,7 @@ export const api = {
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
method: 'POST',
}),
// Advanced Authentication
testSMTP: (data: TestSMTPRequest) =>
request<TestSMTPResponse>('/auth/smtp/test', {
@@ -2903,6 +3010,8 @@ export const api = {
request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
getCloudSettings: (version = '02.04.00.70') =>
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getBuiltinFilaments: () =>
request<BuiltinFilament[]>('/cloud/builtin-filaments'),
getCloudSettingDetail: (settingId: string) =>
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3242,6 +3351,78 @@ export const api = {
body: JSON.stringify(data),
}),
// Inventory
getSpools: (includeArchived = false) =>
request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),
getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),
createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
request<InventorySpool>('/inventory/spools', {
method: 'POST',
body: JSON.stringify(data),
}),
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
request<InventorySpool>(`/inventory/spools/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteSpool: (id: number) =>
request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),
archiveSpool: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
restoreSpool: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
getSpoolKProfiles: (spoolId: number) =>
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {
method: 'PUT',
body: JSON.stringify(profiles),
}),
getAssignments: (printerId?: number) =>
request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
request<SpoolAssignment>('/inventory/assignments', {
method: 'POST',
body: JSON.stringify(data),
}),
unassignSpool: (printerId: number, amsId: number, trayId: number) =>
request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
getSpoolCatalog: () =>
request<SpoolCatalogEntry[]>('/inventory/catalog'),
addCatalogEntry: (data: { name: string; weight: number }) =>
request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),
updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>
request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteCatalogEntry: (id: number) =>
request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
resetSpoolCatalog: () =>
request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
getColorCatalog: () =>
request<ColorCatalogEntry[]>('/inventory/colors'),
addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteColorEntry: (id: number) =>
request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
resetColorCatalog: () =>
request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
lookupColor: (manufacturer: string, colorName: string, material?: string) =>
request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
getSpoolUsageHistory: (spoolId: number, limit = 50) =>
request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
getAllUsageHistory: (limit = 100, printerId?: number) =>
request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
clearSpoolUsageHistory: (spoolId: number) =>
request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
getFilamentPresets: () =>
request<SlicerSetting[]>('/cloud/filaments'),
// Updates
getVersion: () => request<VersionInfo>('/updates/version'),
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Package, Check, Search } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface AssignSpoolModalProps {
isOpen: boolean;
onClose: () => void;
printerId: number;
amsId: number;
trayId: number;
trayInfo?: {
type: string;
color: string;
location: string;
};
}
export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
const [searchFilter, setSearchFilter] = useState('');
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(),
enabled: isOpen,
});
const assignMutation = useMutation({
mutationFn: (spoolId: number) =>
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
queryClient.invalidateQueries({ queryKey: ['printer-status'] });
showToast(t('inventory.assignSuccess'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
},
});
if (!isOpen) return null;
const filteredSpools = spools?.filter((spool: InventorySpool) => {
if (!searchFilter) return true;
const q = searchFilter.toLowerCase();
return (
spool.material.toLowerCase().includes(q) ||
(spool.brand?.toLowerCase().includes(q) ?? false) ||
(spool.color_name?.toLowerCase().includes(q) ?? false) ||
(spool.subtype?.toLowerCase().includes(q) ?? false)
);
});
const handleAssign = () => {
if (selectedSpoolId) {
assignMutation.mutate(selectedSpoolId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('inventory.assignSpool')}</h2>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tray info */}
{trayInfo && (
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
<div className="flex items-center gap-2">
{trayInfo.color && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${trayInfo.color}` }}
/>
)}
<span className="text-white font-medium">{trayInfo.type || 'Empty'}</span>
<span className="text-bambu-gray">({trayInfo.location})</span>
</div>
</div>
)}
{/* Search filter */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder={t('inventory.searchSpools')}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Spool list */}
<div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : filteredSpools && filteredSpools.length > 0 ? (
<div className="max-h-64 overflow-y-auto space-y-2">
{filteredSpools.map((spool: InventorySpool) => (
<button
key={spool.id}
onClick={() => setSelectedSpoolId(spool.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedSpoolId === spool.id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center gap-2">
{spool.rgba && (
<span
className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<p className="text-xs text-bambu-gray">
{spool.color_name || ''}
{spool.label_weight ? ` - ${spool.label_weight}g` : ''}
{spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
</p>
</div>
{selectedSpoolId === spool.id && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noSpools')}</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleAssign}
disabled={!selectedSpoolId || assignMutation.isPending}
>
{assignMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('inventory.assigning')}
</>
) : (
<>
<Package className="w-4 h-4" />
{t('inventory.assignSpool')}
</>
)}
</Button>
</div>
{assignMutation.isError && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(assignMutation.error as Error).message}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,583 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
import { api, getAuthToken } from '../api/client';
import type { ColorCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
export function ColorCatalogSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const [catalog, setCatalog] = useState<ColorCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterManufacturer, setFilterManufacturer] = useState<string>('Bambu Lab');
const fileInputRef = useRef<HTMLInputElement>(null);
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formManufacturer, setFormManufacturer] = useState('');
const [formColorName, setFormColorName] = useState('');
const [formHexColor, setFormHexColor] = useState('#FFFFFF');
const [formMaterial, setFormMaterial] = useState('');
const [saving, setSaving] = useState(false);
// Sync state
const [syncing, setSyncing] = useState(false);
const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);
// Confirmation modals
const [deleteEntry, setDeleteEntry] = useState<ColorCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const loadCatalog = useCallback(async () => {
try {
const entries = await api.getColorCatalog();
setCatalog(entries);
} catch {
showToast(t('settings.colorCatalog.loadFailed'), 'error');
} finally {
setLoading(false);
}
}, [showToast, t]);
useEffect(() => {
loadCatalog();
}, [loadCatalog]);
const manufacturers = [...new Set(catalog.map(e => e.manufacturer))].sort();
const filteredCatalog = catalog.filter(entry => {
const matchesSearch = search === '' ||
entry.manufacturer.toLowerCase().includes(search.toLowerCase()) ||
entry.color_name.toLowerCase().includes(search.toLowerCase()) ||
(entry.material?.toLowerCase().includes(search.toLowerCase()) ?? false);
const matchesManufacturer = filterManufacturer === '' || entry.manufacturer === filterManufacturer;
return matchesSearch && matchesManufacturer;
});
const resetForm = () => {
setFormManufacturer('');
setFormColorName('');
setFormHexColor('#FFFFFF');
setFormMaterial('');
};
const handleAdd = async () => {
if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {
showToast(t('settings.colorCatalog.fieldsRequired'), 'error');
return;
}
setSaving(true);
try {
const entry = await api.addColorEntry({
manufacturer: formManufacturer.trim(),
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
));
setShowAddForm(false);
resetForm();
showToast(t('settings.colorCatalog.colorAdded'), 'success');
} catch {
showToast(t('settings.colorCatalog.addFailed'), 'error');
} finally {
setSaving(false);
}
};
const startEdit = (entry: ColorCatalogEntry) => {
setEditingId(entry.id);
setFormManufacturer(entry.manufacturer);
setFormColorName(entry.color_name);
setFormHexColor(entry.hex_color);
setFormMaterial(entry.material || '');
};
const cancelEdit = () => {
setEditingId(null);
resetForm();
};
const handleUpdate = async (id: number) => {
if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {
showToast(t('settings.colorCatalog.fieldsRequired'), 'error');
return;
}
setSaving(true);
try {
const updated = await api.updateColorEntry(id, {
manufacturer: formManufacturer.trim(),
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
});
setCatalog(prev =>
prev.map(e => e.id === id ? updated : e).sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
)
);
setEditingId(null);
resetForm();
showToast(t('settings.colorCatalog.colorUpdated'), 'success');
} catch {
showToast(t('settings.colorCatalog.updateFailed'), 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteEntry) return;
try {
await api.deleteColorEntry(deleteEntry.id);
setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));
showToast(t('settings.colorCatalog.colorDeleted'), 'success');
} catch {
showToast(t('settings.colorCatalog.deleteFailed'), 'error');
} finally {
setDeleteEntry(null);
}
};
const handleReset = async () => {
setShowResetConfirm(false);
setLoading(true);
try {
await api.resetColorCatalog();
await loadCatalog();
showToast(t('settings.colorCatalog.resetSuccess'), 'success');
} catch {
showToast(t('settings.colorCatalog.resetFailed'), 'error');
setLoading(false);
}
};
const handleSync = async () => {
setSyncing(true);
setSyncProgress(null);
try {
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/inventory/colors/sync', { method: 'POST', headers });
if (!response.ok) throw new Error('Failed to start sync');
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
setSyncProgress({ fetched: data.total_fetched, total: data.total_available });
} else if (data.type === 'complete') {
if (data.added === 0) {
showToast(t('settings.colorCatalog.syncUpToDate', { count: data.total_fetched }), 'success');
} else {
showToast(t('settings.colorCatalog.syncComplete', { added: data.added, skipped: data.skipped }), 'success');
}
} else if (data.type === 'error') {
showToast(`${t('settings.colorCatalog.syncError')}: ${data.error}`, 'error');
}
} catch {
// Ignore parse errors
}
}
}
}
await loadCatalog();
} catch {
showToast(t('settings.colorCatalog.syncFailed'), 'error');
} finally {
setSyncing(false);
setSyncProgress(null);
}
};
const handleExport = () => {
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
manufacturer, color_name, hex_color, material,
}));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'color-catalog.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(t('settings.colorCatalog.exported', { count: catalog.length }), 'success');
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text) as Array<{
manufacturer: string; color_name: string; hex_color: string; material?: string | null;
}>;
if (!Array.isArray(data)) throw new Error('Invalid format');
let added = 0;
let skipped = 0;
for (const item of data) {
if (!item.manufacturer || !item.color_name || !item.hex_color) { skipped++; continue; }
const exists = catalog.some(c =>
c.manufacturer.toLowerCase() === item.manufacturer.toLowerCase() &&
c.color_name.toLowerCase() === item.color_name.toLowerCase() &&
(c.material || '').toLowerCase() === (item.material || '').toLowerCase()
);
if (exists) { skipped++; continue; }
try {
const entry = await api.addColorEntry({
manufacturer: item.manufacturer,
color_name: item.color_name,
hex_color: item.hex_color,
material: item.material || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
));
added++;
} catch { skipped++; }
}
showToast(t('settings.colorCatalog.imported', { added, skipped }), 'success');
} catch {
showToast(t('settings.colorCatalog.importFailed'), 'error');
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-5 h-5 text-bambu-gray" />
<h2 className="text-lg font-semibold text-white">{t('settings.colorCatalog.title')}</h2>
<span className="text-sm text-bambu-gray">({catalog.length})</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={handleSync}
disabled={syncing}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.colorCatalog.syncTooltip')}
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Cloud className="w-4 h-4" />}
<span className="hidden sm:inline">
{syncing
? syncProgress
? `${Math.min(syncProgress.fetched, syncProgress.total)} / ${syncProgress.total}`
: t('settings.colorCatalog.starting')
: t('settings.colorCatalog.sync')}
</span>
</button>
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('settings.colorCatalog.description')}
</p>
{/* Search and filter */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.searchColors')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterManufacturer}
onChange={(e) => setFilterManufacturer(e.target.value)}
>
<option value="">{t('settings.colorCatalog.allManufacturers')}</option>
{manufacturers.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
{/* Add form */}
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.colorCatalog.addNewColor')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-2 items-end">
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.manufacturer')}
value={formManufacturer}
onChange={(e) => setFormManufacturer(e.target.value)}
/>
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.colorName')}
value={formColorName}
onChange={(e) => setFormColorName(e.target.value)}
/>
<div className="flex items-center gap-2">
<input
type="color"
className="w-10 h-10 rounded cursor-pointer border border-bambu-dark-tertiary"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
<input
type="text"
className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder="#FFFFFF"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</div>
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.materialOptional')}
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
<div className="flex gap-2">
<button
onClick={handleAdd}
disabled={saving}
className="flex-1 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-1"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); resetForm(); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
{/* Filter info */}
{(search || filterManufacturer) && (
<div className="text-xs text-bambu-gray">
{t('settings.colorCatalog.showing', { filtered: filteredCatalog.length, total: catalog.length })}
</div>
)}
{/* Catalog list */}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : (
<div className="max-h-[400px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
<th className="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-bambu-gray">
{search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
{editingId === entry.id ? (
<>
<td className="px-3 py-2">
<input
type="color"
className="w-8 h-8 rounded cursor-pointer border border-bambu-dark-tertiary"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formManufacturer}
onChange={(e) => setFormManufacturer(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formColorName}
onChange={(e) => setFormColorName(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-3 py-2">
<div
className="w-8 h-8 rounded border border-bambu-dark-tertiary"
style={{ backgroundColor: entry.hex_color }}
title={entry.hex_color}
/>
</td>
<td className="px-3 py-2 text-white">{entry.manufacturer}</td>
<td className="px-3 py-2 text-white">{entry.color_name}</td>
<td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
{/* Delete confirmation */}
{deleteEntry && (
<ConfirmModal
title={t('settings.colorCatalog.deleteColor')}
message={t('settings.colorCatalog.deleteConfirm', { name: `${deleteEntry.manufacturer} - ${deleteEntry.color_name}` })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteEntry(null)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal
title={t('settings.colorCatalog.resetCatalog')}
message={t('settings.colorCatalog.resetConfirm')}
confirmText={t('common.reset')}
variant="danger"
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
</Card>
);
}
@@ -0,0 +1,187 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, Eye, EyeOff, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
export interface ColumnConfig {
id: string;
label: string;
visible: boolean;
}
interface ColumnConfigModalProps {
isOpen: boolean;
onClose: () => void;
columns: ColumnConfig[];
defaultColumns: ColumnConfig[];
onSave: (columns: ColumnConfig[]) => void;
}
export function ColumnConfigModal({ isOpen, onClose, columns, defaultColumns, onSave }: ColumnConfigModalProps) {
const { t } = useTranslation();
const [localColumns, setLocalColumns] = useState<ColumnConfig[]>(columns);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
useEffect(() => {
if (isOpen) {
setLocalColumns(columns.map((c) => ({ ...c })));
}
}, [isOpen, columns]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const toggleVisibility = (index: number) => {
setLocalColumns((prev) =>
prev.map((col, i) => (i === index ? { ...col, visible: !col.visible } : col))
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= localColumns.length) return;
setLocalColumns((prev) => {
const newColumns = [...prev];
const [moved] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, moved);
return newColumns;
});
};
const handleDragStart = (e: React.DragEvent, index: number) => {
draggedIndexRef.current = index;
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const from = draggedIndexRef.current;
if (from !== null && from !== index) {
moveColumn(from, index);
draggedIndexRef.current = index;
setDraggedIndex(index);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDragEnd = () => {
draggedIndexRef.current = null;
setDraggedIndex(null);
};
const resetToDefaults = () => {
setLocalColumns(defaultColumns.map((c) => ({ ...c })));
};
const handleSave = () => {
onSave(localColumns);
onClose();
};
const visibleCount = localColumns.filter((c) => c.visible).length;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6 flex flex-col min-h-0">
{/* Header */}
<h3 className="text-lg font-semibold text-white mb-2">{t('inventory.configureColumns')}</h3>
<p className="text-sm text-bambu-gray mb-4">
{t('inventory.configureColumnsDesc')}
<span className="ml-2 text-bambu-gray/60">
({visibleCount} {t('inventory.of')} {localColumns.length} {t('inventory.visible')})
</span>
</p>
{/* Column list */}
<div className="space-y-1 overflow-y-auto flex-1 min-h-0 pr-1">
{localColumns.map((column, index) => (
<div
key={column.id}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
draggedIndex === index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-tertiary/50'
} ${!column.visible ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
>
{/* Drag Handle */}
<div className="cursor-grab text-bambu-gray/50 hover:text-bambu-gray">
<GripVertical className="w-4 h-4" />
</div>
{/* Column Name */}
<span className="flex-1 font-medium text-sm text-white">{column.label}</span>
{/* Move Buttons */}
<div className="flex items-center gap-0.5">
<button
onClick={() => moveColumn(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
title={t('inventory.moveUp')}
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => moveColumn(index, index + 1)}
disabled={index === localColumns.length - 1}
className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
title={t('inventory.moveDown')}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
{/* Visibility Toggle */}
<button
onClick={() => toggleVisibility(index)}
className={`p-1.5 rounded transition-colors ${
column.visible
? 'text-bambu-green hover:bg-bambu-green/10'
: 'text-bambu-gray/50 hover:bg-bambu-dark-secondary'
}`}
title={column.visible ? t('inventory.hideColumn') : t('inventory.showColumn')}
>
{column.visible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={resetToDefaults} className="mr-auto">
<RotateCcw className="w-4 h-4" />
{t('inventory.reset')}
</Button>
<Button variant="secondary" onClick={onClose}>
{t('inventory.cancel')}
</Button>
<Button onClick={handleSave}>
{t('inventory.applyChanges')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -226,11 +226,12 @@ export function ConfigureAmsSlotModal({
const [showSuccess, setShowSuccess] = useState(false);
const [showExtendedColors, setShowExtendedColors] = useState(false);
// Fetch cloud settings
const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
// Fetch cloud settings (gracefully handle 401 when logged out)
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
enabled: isOpen,
retry: false,
});
// Fetch local presets
@@ -240,6 +241,14 @@ export function ConfigureAmsSlotModal({
enabled: isOpen,
});
// Fetch built-in filament names (static fallback)
const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({
queryKey: ['builtinFilaments'],
queryFn: () => api.getBuiltinFilaments(),
enabled: isOpen,
staleTime: Infinity,
});
// Fetch K profiles
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
queryKey: ['kprofiles', printerId, nozzleDiameter],
@@ -252,23 +261,29 @@ export function ConfigureAmsSlotModal({
mutationFn: async () => {
if (!selectedPresetId) throw new Error('No filament preset selected');
// Check if this is a local preset
// Determine preset source
const isLocal = selectedPresetId.startsWith('local_');
const isBuiltin = selectedPresetId.startsWith('builtin_');
const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
const localPreset = isLocal
? localPresets?.filament.find(p => p.id === localId)
: null;
const builtinPreset = isBuiltin
? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
: null;
// Get the selected cloud preset details (null for local presets)
const selectedPreset = !isLocal
// Get the selected cloud preset details (null for local/builtin presets)
const selectedPreset = (!isLocal && !isBuiltin)
? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
: null;
if (!isLocal && !selectedPreset) throw new Error('Selected preset not found');
if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found');
if (isLocal && !localPreset) throw new Error('Selected local preset not found');
if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
// Parse the preset name for filament info
const presetName = isLocal ? localPreset!.name : selectedPreset!.name;
const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name;
const parsed = parsePresetName(presetName);
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
@@ -287,6 +302,10 @@ export function ConfigureAmsSlotModal({
// Local presets have no Bambu Cloud mapping
trayInfoIdx = '';
settingId = '';
} else if (isBuiltin) {
// Built-in presets use the filament_id directly as tray_info_idx
trayInfoIdx = builtinFilamentId!;
settingId = '';
} else {
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
@@ -312,7 +331,7 @@ export function ConfigureAmsSlotModal({
let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
if (!isLocal || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
// Fall back to material-based defaults
const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase();
if (material.includes('PLA')) {
@@ -368,8 +387,8 @@ export function ConfigureAmsSlotModal({
// Save the preset mapping so we can display the correct name in the UI
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
// which can't be resolved to a name via the filamentInfo API
const mappingPresetId = isLocal ? `local_${localId}` : selectedPresetId;
const mappingSource = isLocal ? 'local' : 'cloud';
const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId;
const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud';
try {
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
} catch (e) {
@@ -405,15 +424,28 @@ export function ConfigureAmsSlotModal({
},
});
// Unified preset item for the list (cloud + local)
type PresetItem = { id: string; name: string; source: 'cloud' | 'local'; isUser: boolean };
// Unified preset item for the list (cloud + local + builtin fallback)
type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean };
// Filter filament presets based on search (merged cloud + local)
// Filter filament presets based on search (merged cloud + local + builtin)
const filteredPresets = useMemo(() => {
const query = searchQuery.toLowerCase();
const items: PresetItem[] = [];
// Add local presets first
// Collect IDs already covered by cloud and local to avoid duplicates in fallback
const coveredIds = new Set<string>();
// 1. Cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
coveredIds.add(cp.setting_id);
if (!query || cp.name.toLowerCase().includes(query)) {
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
}
}
}
// 2. Local presets
if (localPresets?.filament) {
for (const lp of localPresets.filament) {
if (!query || lp.name.toLowerCase().includes(query)) {
@@ -422,35 +454,46 @@ export function ConfigureAmsSlotModal({
}
}
// Add cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
if (!query || cp.name.toLowerCase().includes(query)) {
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
// 3. Built-in filament names (fallback — only add entries not already covered)
if (builtinFilaments) {
for (const bf of builtinFilaments) {
if (coveredIds.has(bf.filament_id)) continue;
// Convert filament_id to setting_id format for cloud compatibility
// e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF)
const settingId = bf.filament_id.startsWith('GF')
? 'GFS' + bf.filament_id.slice(2)
: bf.filament_id;
if (coveredIds.has(settingId)) continue;
if (!query || bf.name.toLowerCase().includes(query)) {
items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });
}
}
}
// Sort: local first, then user cloud presets, then built-in, alphabetically within groups
// Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback
return items.sort((a, b) => {
if (a.source === 'local' && b.source !== 'local') return -1;
if (a.source !== 'local' && b.source === 'local') return 1;
const sourceOrder = { cloud: 0, local: 1, builtin: 2 };
if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
if (a.isUser && !b.isUser) return -1;
if (!a.isUser && b.isUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, localPresets?.filament, searchQuery]);
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
if (!selectedPresetId) return null;
// Resolve the name from either local or cloud presets
// Resolve the name from cloud, local, or builtin presets
let presetName: string | null = null;
if (selectedPresetId.startsWith('local_')) {
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
const lp = localPresets?.filament.find(p => p.id === localId);
presetName = lp?.name || null;
} else if (selectedPresetId.startsWith('builtin_')) {
const filamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
presetName = bf?.name || null;
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
@@ -470,7 +513,7 @@ export function ConfigureAmsSlotModal({
material: parsed.material,
brand: parsed.brand,
};
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament]);
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]);
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -596,7 +639,7 @@ export function ConfigureAmsSlotModal({
if (!isOpen) return null;
const isLoading = settingsLoading || localLoading || kprofilesLoading;
const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;
const canSave = selectedPresetId && !configureMutation.isPending;
// Get display color (custom or slot default)
@@ -703,6 +746,11 @@ export function ConfigureAmsSlotModal({
{t('profiles.localProfiles.badge')}
</span>
)}
{preset.source === 'builtin' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
{t('configureAmsSlot.builtin')}
</span>
)}
{preset.isUser && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
{t('configureAmsSlot.custom')}
+53 -2
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
interface FilamentData {
vendor: 'Bambu Lab' | 'Generic';
@@ -21,6 +21,12 @@ interface SpoolmanConfig {
spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
}
interface InventoryConfig {
onAssignSpool?: () => void;
onUnassignSpool?: () => void;
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
}
interface ConfigureSlotConfig {
enabled: boolean;
onConfigure?: () => void;
@@ -32,6 +38,7 @@ interface FilamentHoverCardProps {
disabled?: boolean;
className?: string;
spoolman?: SpoolmanConfig;
inventory?: InventoryConfig;
configureSlot?: ConfigureSlotConfig;
}
@@ -39,7 +46,7 @@ interface FilamentHoverCardProps {
* A hover card that displays filament details when hovering over AMS slots.
* Replaces the basic browser tooltip with a styled popover.
*/
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
@@ -319,6 +326,50 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
</div>
)}
{/* Inventory section - assign/unassign for non-Bambu spools */}
{inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
{inventory.assignedSpool ? (
<>
<div className="flex items-center gap-1.5">
<Package className="w-3 h-3 text-bambu-green" />
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
{t('inventory.assigned')}
</span>
</div>
<p className="text-xs text-white truncate">
{inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
{inventory.assignedSpool.material}
{inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
</p>
{inventory.onUnassignSpool && (
<button
onClick={(e) => {
e.stopPropagation();
inventory.onUnassignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
>
<Unlink className="w-3.5 h-3.5" />
{t('inventory.unassignSpool')}
</button>
)}
</>
) : inventory.onAssignSpool ? (
<button
onClick={(e) => {
e.stopPropagation();
inventory.onAssignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
>
<Package className="w-3.5 h-3.5" />
{t('inventory.assignSpool')}
</button>
) : null}
</div>
)}
{/* Configure slot section - always show if enabled */}
{configureSlot?.enabled && (
<div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
+15 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -29,6 +29,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
@@ -118,6 +119,13 @@ export function Layout() {
refetchInterval: 60 * 60 * 1000, // Check every hour
});
// Fetch Spoolman settings to determine if inventory should be hidden
const { data: spoolmanSettings } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
staleTime: 5 * 60 * 1000,
});
// Fetch external links for sidebar
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
@@ -189,13 +197,13 @@ export function Layout() {
// Determine if settings should be hidden (user role and auth enabled)
const hideSettings = authEnabled && user?.role === 'user';
// Hide inventory when Spoolman mode is active
const hideInventory = spoolmanSettings?.spoolman_enabled === 'true';
// Add items in stored order
for (const id of sidebarOrder) {
// Skip settings if user is not admin
if (hideSettings && id === 'settings') {
continue;
}
if (hideSettings && id === 'settings') continue;
if (hideInventory && id === 'inventory') continue;
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
@@ -204,10 +212,8 @@ export function Layout() {
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
// Skip settings if user is not admin
if (hideSettings && item.id === 'settings') {
continue;
}
if (hideSettings && item.id === 'settings') continue;
if (hideInventory && item.id === 'inventory') continue;
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
+107 -143
View File
@@ -1,190 +1,154 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Link2, Check } from 'lucide-react';
import { X, Loader2, Search, Link } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface LinkSpoolModalProps {
isOpen: boolean;
onClose: () => void;
tagUid: string;
trayUuid: string;
trayInfo?: {
type: string;
color: string;
location: string;
};
printerId: number;
amsId: number;
trayId: number;
}
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, amsId, trayId }: LinkSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
const [search, setSearch] = useState('');
// Fetch unlinked spools
const { data: unlinkedSpools, isLoading } = useQuery({
queryKey: ['unlinked-spools'],
queryFn: api.getUnlinkedSpools,
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(false),
enabled: isOpen,
});
// Link mutation
// Filter to untagged spools matching search
const filteredSpools = useMemo(() => {
if (!spools) return [];
return spools.filter((s: InventorySpool) => {
if (s.tag_uid || s.tray_uuid) return false; // Already tagged
if (!search) return true;
const q = search.toLowerCase();
return (
s.material.toLowerCase().includes(q) ||
(s.brand && s.brand.toLowerCase().includes(q)) ||
(s.color_name && s.color_name.toLowerCase().includes(q))
);
});
}, [spools, search]);
const linkMutation = useMutation({
mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
mutationFn: (spoolId: number) =>
api.linkTagToSpool(spoolId, {
tag_uid: tagUid || undefined,
tray_uuid: trayUuid || undefined,
tag_type: trayUuid ? 'bambulab' : 'generic',
data_origin: 'nfc_link',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
showToast(t('spoolman.linkSuccess'), 'success');
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: ['inventory-assignments'] });
showToast(t('inventory.tagLinked'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
onError: (err: Error) => {
showToast(err.message || t('inventory.tagLinkFailed'), 'error');
},
});
if (!isOpen) return null;
const handleLink = () => {
if (selectedSpoolId) {
linkMutation.mutate(selectedSpoolId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-bambu-dark-secondary rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[80vh] flex flex-col border border-bambu-dark-tertiary">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Link className="w-5 h-5 text-bambu-green" />
{t('inventory.linkToSpool')}
</h3>
<p className="text-xs text-bambu-gray mt-1">
AMS {amsId} T{trayId} &middot; Printer #{printerId}
</p>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<button onClick={onClose} className="p-1 text-bambu-gray hover:text-white rounded transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tray info */}
{trayInfo && (
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
<div className="flex items-center gap-2">
{trayInfo.color && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${trayInfo.color}` }}
/>
)}
<span className="text-white font-medium">{trayInfo.type}</span>
<span className="text-bambu-gray">({trayInfo.location})</span>
</div>
</div>
)}
{/* Spool UUID */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
{/* Search */}
<div className="p-4 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('inventory.searchSpools')}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Spool list */}
<div>
<p className="text-sm text-bambu-gray mb-2">
{t('spoolman.selectSpool')}:
{(tagUid || trayUuid) && (
<p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={tagUid || trayUuid}>
Tag: {tagUid || trayUuid}
</p>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : unlinkedSpools && unlinkedSpools.length > 0 ? (
<div className="max-h-64 overflow-y-auto space-y-2">
{unlinkedSpools.map((spool) => (
<button
key={spool.id}
onClick={() => setSelectedSpoolId(spool.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedSpoolId === spool.id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center gap-2">
{spool.filament_color_hex && (
<span
className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${spool.filament_color_hex}` }}
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{spool.filament_name || 'Unknown filament'}
</p>
<p className="text-xs text-bambu-gray">
{spool.filament_material || 'Unknown'}
{spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
{spool.location && ` - ${spool.location}`}
</p>
</div>
{selectedSpoolId === spool.id && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('spoolman.noUnlinkedSpools')}</p>
</div>
)}
</div>
{/* Spool List */}
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : filteredSpools.length === 0 ? (
<p className="text-center text-bambu-gray py-8 text-sm">
{t('inventory.noSpoolsMatch')}
</p>
) : (
filteredSpools.map((spool: InventorySpool) => (
<button
key={spool.id}
onClick={() => linkMutation.mutate(spool.id)}
disabled={linkMutation.isPending}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}
{spool.subtype ? ` ${spool.subtype}` : ''}
</div>
<div className="text-xs text-bambu-gray truncate">
{spool.color_name || 'No color'} &middot; #{spool.id}
</div>
</div>
<span className="text-xs text-bambu-gray">
{Math.round(spool.label_weight - spool.weight_used)}g
</span>
</button>
))
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleLink}
disabled={!selectedSpoolId || linkMutation.isPending}
>
{linkMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('spoolman.syncing')}
</>
) : (
<>
<Link2 className="w-4 h-4" />
{t('spoolman.linkToSpoolman')}
</>
)}
<div className="p-4 border-t border-white/10 flex justify-end">
<Button variant="ghost" onClick={onClose}>
{t('inventory.cancel') || 'Cancel'}
</Button>
</div>
{/* Error */}
{linkMutation.isError && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(linkMutation.error as Error).message}
</div>
)}
</div>
</div>
);
@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
export function SpoolCatalogSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formWeight, setFormWeight] = useState('');
const [saving, setSaving] = useState(false);
// Confirmation modals
const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const loadCatalog = useCallback(async () => {
try {
const entries = await api.getSpoolCatalog();
setCatalog(entries);
} catch {
showToast(t('settings.catalog.loadFailed'), 'error');
} finally {
setLoading(false);
}
}, [showToast, t]);
useEffect(() => {
loadCatalog();
}, [loadCatalog]);
const filteredCatalog = catalog.filter(entry =>
entry.name.toLowerCase().includes(search.toLowerCase())
);
const handleAdd = async () => {
if (!formName.trim() || !formWeight) {
showToast(t('settings.catalog.nameWeightRequired'), 'error');
return;
}
setSaving(true);
try {
const entry = await api.addCatalogEntry({ name: formName.trim(), weight: parseInt(formWeight) });
setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));
setShowAddForm(false);
setFormName('');
setFormWeight('');
showToast(t('settings.catalog.entryAdded'), 'success');
} catch {
showToast(t('settings.catalog.addFailed'), 'error');
} finally {
setSaving(false);
}
};
const startEdit = (entry: SpoolCatalogEntry) => {
setEditingId(entry.id);
setFormName(entry.name);
setFormWeight(entry.weight.toString());
};
const cancelEdit = () => {
setEditingId(null);
setFormName('');
setFormWeight('');
};
const handleUpdate = async (id: number) => {
if (!formName.trim() || !formWeight) {
showToast(t('settings.catalog.nameWeightRequired'), 'error');
return;
}
setSaving(true);
try {
const updated = await api.updateCatalogEntry(id, { name: formName.trim(), weight: parseInt(formWeight) });
setCatalog(prev => prev.map(e => e.id === id ? updated : e).sort((a, b) => a.name.localeCompare(b.name)));
setEditingId(null);
setFormName('');
setFormWeight('');
showToast(t('settings.catalog.entryUpdated'), 'success');
} catch {
showToast(t('settings.catalog.updateFailed'), 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteEntry) return;
try {
await api.deleteCatalogEntry(deleteEntry.id);
setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));
showToast(t('settings.catalog.entryDeleted'), 'success');
} catch {
showToast(t('settings.catalog.deleteFailed'), 'error');
} finally {
setDeleteEntry(null);
}
};
const handleReset = async () => {
setShowResetConfirm(false);
setLoading(true);
try {
await api.resetSpoolCatalog();
await loadCatalog();
showToast(t('settings.catalog.resetSuccess'), 'success');
} catch {
showToast(t('settings.catalog.resetFailed'), 'error');
setLoading(false);
}
};
const handleExport = () => {
const exportData = catalog.map(({ name, weight }) => ({ name, weight }));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'spool-catalog.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(t('settings.catalog.exported', { count: catalog.length }), 'success');
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text) as Array<{ name: string; weight: number }>;
if (!Array.isArray(data)) throw new Error('Invalid format');
let added = 0;
let skipped = 0;
for (const item of data) {
if (!item.name || typeof item.weight !== 'number') { skipped++; continue; }
const exists = catalog.some(c => c.name.toLowerCase() === item.name.toLowerCase());
if (exists) { skipped++; continue; }
try {
const entry = await api.addCatalogEntry({ name: item.name, weight: item.weight });
setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));
added++;
} catch { skipped++; }
}
showToast(t('settings.catalog.imported', { added, skipped }), 'success');
} catch {
showToast(t('settings.catalog.importFailed'), 'error');
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-bambu-gray" />
<h2 className="text-lg font-semibold text-white">{t('settings.catalog.spoolCatalog')}</h2>
<span className="text-sm text-bambu-gray">({catalog.length})</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.exportTooltip')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.importTooltip')}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.resetTooltip')}
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('settings.catalog.spoolCatalogDescription')}
</p>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.searchCatalog')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Add form */}
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0">
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.namePlaceholder')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<input
type="number"
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
placeholder="g"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
<span className="text-bambu-gray shrink-0">g</span>
<button
onClick={handleAdd}
disabled={saving}
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Catalog list */}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : (
<div className="max-h-[400px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
<th className="px-4 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
{editingId === entry.id ? (
<>
<td className="px-4 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
</td>
<td className="px-4 py-2 text-center">
<span className="text-xs text-bambu-gray">-</span>
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2 text-white">{entry.name}</td>
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
<td className="px-4 py-2 text-center">
{entry.is_default ? (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('settings.catalog.default')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
{t('settings.catalog.custom')}
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
{/* Delete confirmation */}
{deleteEntry && (
<ConfirmModal
title={t('settings.catalog.deleteEntry')}
message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteEntry(null)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal
title={t('settings.catalog.resetCatalog')}
message={t('settings.catalog.resetConfirm')}
confirmText={t('common.reset')}
variant="danger"
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
</Card>
);
}
+484
View File
@@ -0,0 +1,484 @@
import { useState, useEffect, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
import { defaultFormData, validateForm } from './spool-form/types';
import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, saveRecentColor } from './spool-form/utils';
import { FilamentSection } from './spool-form/FilamentSection';
import { ColorSection } from './spool-form/ColorSection';
import { AdditionalSection } from './spool-form/AdditionalSection';
import { PAProfileSection } from './spool-form/PAProfileSection';
import { SpoolUsageHistory } from './SpoolUsageHistory';
type TabId = 'filament' | 'pa-profile';
interface SpoolFormModalProps {
isOpen: boolean;
onClose: () => void;
spool?: InventorySpool | null;
printersWithCalibrations?: PrinterWithCalibrations[];
}
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [] }: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isEditing = !!spool;
// Form state
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('filament');
// Cloud presets
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
const [presetInputValue, setPresetInputValue] = useState('');
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
// Color state
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
// PA Profile state
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
// Use prop if provided, otherwise use self-fetched data
const resolvedCalibrations = printersWithCalibrations.length > 0
? printersWithCalibrations
: fetchedCalibrations;
// Count selected PA profiles for tab badge
const selectedProfileCount = useMemo(() => {
return selectedProfiles.size;
}, [selectedProfiles]);
// Load recent colors on mount
useEffect(() => {
setRecentColors(loadRecentColors());
}, []);
// Fetch cloud presets and catalog when modal opens
useEffect(() => {
if (isOpen) {
const fetchData = async () => {
setLoadingCloudPresets(true);
try {
const status = await api.getCloudStatus();
setCloudAuthenticated(status.is_authenticated);
if (status.is_authenticated) {
const presets = await api.getFilamentPresets();
setCloudPresets(presets);
}
} catch (e) {
console.error('Failed to fetch cloud presets:', e);
setCloudAuthenticated(false);
} finally {
setLoadingCloudPresets(false);
}
};
fetchData();
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
(async () => {
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 {
// Printer may not support K-profiles
}
}
results.push({ printer: { ...printer, connected }, calibrations });
}
setFetchedCalibrations(results);
} catch (e) {
console.error('Failed to fetch printer calibrations:', e);
}
})();
}
}
}, [isOpen]);
// Build filament options from cloud presets
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set()),
[cloudPresets],
);
// Extract brands from presets
const availableBrands = useMemo(
() => extractBrandsFromPresets(cloudPresets),
[cloudPresets],
);
// Find selected preset option
const selectedPresetOption = useMemo(
() => findPresetOption(formData.slicer_filament, filamentOptions),
[formData.slicer_filament, filamentOptions],
);
// Reset form when modal opens/closes or spool changes
useEffect(() => {
if (isOpen) {
if (spool) {
setFormData({
material: spool.material || '',
subtype: spool.subtype || '',
brand: spool.brand || '',
color_name: spool.color_name || '',
rgba: spool.rgba || '808080FF',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
});
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
// Load K-profiles for this spool
if (spool.k_profiles && spool.k_profiles.length > 0) {
const profileKeys = new Set<string>();
for (const p of spool.k_profiles) {
if (p.cali_idx !== null && p.cali_idx !== undefined) {
profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
}
}
setSelectedProfiles(profileKeys);
} else {
setSelectedProfiles(new Set());
}
} else {
setFormData(defaultFormData);
setPresetInputValue('');
setSelectedProfiles(new Set());
}
setErrors({});
setActiveTab('filament');
}
}, [isOpen, spool]);
// Expand all printers in PA profile section when calibrations are available
useEffect(() => {
if (isOpen && resolvedCalibrations.length > 0) {
setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
}
}, [isOpen, resolvedCalibrations]);
// Update field helper
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
setFormData(prev => ({ ...prev, [key]: value }));
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
};
// Handle color selection
const handleColorUsed = (color: ColorPreset) => {
setRecentColors(prev => saveRecentColor(color, prev));
};
// Mutations
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
onSuccess: async (newSpool) => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
// Save K-profiles if any selected
if (selectedProfiles.size > 0 && newSpool?.id) {
await saveKProfiles(newSpool.id);
}
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
// Save K-profiles
if (spool?.id) {
await saveKProfiles(spool.id);
}
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Save K-profiles for selected calibrations
const saveKProfiles = async (spoolId: number) => {
if (selectedProfiles.size === 0) {
// Clear existing K-profiles
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);
// Find the matching calibration
const pc = resolvedCalibrations.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) {
try {
await api.saveSpoolKProfiles(spoolId, profiles);
} catch (e) {
console.error('Failed to save K-profiles:', e);
}
}
};
if (!isOpen) return null;
const handleSubmit = () => {
const validation = validateForm(formData);
if (!validation.isValid) {
setErrors(validation.errors);
// Switch to filament tab if there are errors there
if (validation.errors.slicer_filament || validation.errors.material) {
setActiveTab('filament');
}
return;
}
// Find preset name from selected option
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
const data: Record<string, unknown> = {
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,
weight_used: spool?.weight_used ?? 0,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: formData.note || null,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
</h2>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
<button
onClick={() => setActiveTab('filament')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'filament'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Palette className="w-4 h-4" />
{t('inventory.filamentInfoTab')}
</button>
<button
onClick={() => setActiveTab('pa-profile')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'pa-profile'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Beaker className="w-4 h-4" />
{t('inventory.paProfileTab')}
{selectedProfileCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{selectedProfileCount}
</span>
)}
</button>
</div>
{/* Content */}
<div className="p-4 overflow-y-auto flex-1">
{activeTab === 'filament' ? (
<div className="space-y-6">
{/* Filament Info Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.filamentInfo')}
</h3>
<FilamentSection
formData={formData}
updateField={updateField}
cloudAuthenticated={cloudAuthenticated}
loadingCloudPresets={loadingCloudPresets}
presetInputValue={presetInputValue}
setPresetInputValue={setPresetInputValue}
selectedPresetOption={selectedPresetOption}
filamentOptions={filamentOptions}
availableBrands={availableBrands}
/>
{errors.slicer_filament && (
<p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
)}
{errors.material && (
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
)}
</div>
{/* Color Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.color')}
</h3>
<ColorSection
formData={formData}
updateField={updateField}
recentColors={recentColors}
onColorUsed={handleColorUsed}
/>
</div>
{/* Additional Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.additional')}
</h3>
<AdditionalSection
formData={formData}
updateField={updateField}
spoolCatalog={spoolCatalog}
/>
</div>
{/* Usage History (only when editing) */}
{isEditing && spool && (
<div>
<SpoolUsageHistory spoolId={spool.id} />
</div>
)}
</div>
) : (
<PAProfileSection
formData={formData}
updateField={updateField}
printersWithCalibrations={resolvedCalibrations}
selectedProfiles={selectedProfiles}
setSelectedProfiles={setSelectedProfiles}
expandedPrinters={expandedPrinters}
setExpandedPrinters={setExpandedPrinters}
/>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditing ? t('common.save') : t('inventory.addSpool')}
</>
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Loader2, Trash2, Clock } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolUsageRecord } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface SpoolUsageHistoryProps {
spoolId: number;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
const STATUS_COLORS: Record<string, string> = {
completed: 'text-bambu-green',
failed: 'text-red-400',
aborted: 'text-yellow-400',
};
export function SpoolUsageHistory({ spoolId }: SpoolUsageHistoryProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: history, isLoading } = useQuery({
queryKey: ['spool-usage', spoolId],
queryFn: () => api.getSpoolUsageHistory(spoolId),
});
const clearMutation = useMutation({
mutationFn: () => api.clearSpoolUsageHistory(spoolId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolId] });
showToast(t('inventory.historyCleared'), 'success');
},
});
if (isLoading) {
return (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-bambu-green" />
</div>
);
}
if (!history || history.length === 0) {
return (
<div className="text-center py-4 text-bambu-gray text-sm">
<Clock className="w-5 h-5 mx-auto mb-2 opacity-50" />
{t('inventory.noUsageHistory')}
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-white">{t('inventory.usageHistory')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
className="text-xs text-bambu-gray hover:text-red-400"
>
<Trash2 className="w-3 h-3 mr-1" />
{t('inventory.clearHistory')}
</Button>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{history.map((record: SpoolUsageRecord) => (
<div
key={record.id}
className="flex items-center justify-between p-2 rounded bg-bambu-dark/50 text-xs"
>
<div className="flex-1 min-w-0">
<span className="text-bambu-gray">{formatDate(record.created_at)}</span>
{record.print_name && (
<span className="text-white ml-2 truncate" title={record.print_name}>
{record.print_name}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<span className="text-white font-medium">{record.weight_used.toFixed(1)}g</span>
<span className="text-bambu-gray">({record.percent_used}%)</span>
<span className={STATUS_COLORS[record.status] || 'text-bambu-gray'}>
{record.status}
</span>
</div>
</div>
))}
</div>
</div>
);
}
+332 -279
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react';
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolmanSyncResult, Printer } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
@@ -146,7 +146,7 @@ export function SpoolmanSettings() {
<CardHeader>
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
</div>
</CardHeader>
<CardContent>
@@ -164,7 +164,7 @@ export function SpoolmanSettings() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
</div>
{saveMutation.isPending && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
@@ -173,303 +173,356 @@ export function SpoolmanSettings() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
{t('settings.filamentTrackingDesc')}
</p>
{/* Info banner about sync requirements */}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-blue-300">
<p className="font-medium mb-1">How Sync Works</p>
<ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
<li>Only official Bambu Lab spools with RFID are synced</li>
<li>New spools are auto-created in Spoolman on first sync</li>
<li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
</ul>
<p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
<p className="text-blue-300/80">
To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
</p>
</div>
</div>
</div>
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-white">Enable Spoolman</p>
<p className="text-sm text-bambu-gray">
Sync filament data with Spoolman server
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localEnabled}
onChange={(e) => setLocalEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* URL input */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Spoolman URL
</label>
<input
type="text"
placeholder="http://192.168.1.100:7912"
value={localUrl}
onChange={(e) => setLocalUrl(e.target.value)}
disabled={!localEnabled}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50"
/>
<p className="text-xs text-bambu-gray mt-1">
URL of your Spoolman server (e.g., http://localhost:7912)
</p>
</div>
{/* Sync mode */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Sync Mode
</label>
<select
value={localSyncMode}
onChange={(e) => setLocalSyncMode(e.target.value)}
disabled={!localEnabled}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
{/* Mode selector cards */}
<div className="grid grid-cols-2 gap-3">
{/* Built-in Inventory */}
<button
type="button"
onClick={() => setLocalEnabled(false)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
!localEnabled
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
}`}
>
<option value="auto">Automatic</option>
<option value="manual">Manual Only</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
{localSyncMode === 'auto'
? 'AMS data syncs automatically when changes are detected'
: 'Only sync when manually triggered'}
</p>
</div>
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
{localSyncMode === 'auto' && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.disableWeightSyncDesc')}
</p>
<div className="flex items-center gap-2 mb-1.5">
<Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
{t('settings.trackingModeBuiltIn')}
</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localDisableWeightSync}
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
disabled={!localEnabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
{localDisableWeightSync && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.reportPartialUsageDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localReportPartialUsage}
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
disabled={!localEnabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{/* Connection status */}
{localEnabled && (
<div className="pt-2 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">Status:</span>
{statusLoading ? (
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
) : status?.connected ? (
<span className="flex items-center gap-1 text-sm text-green-500">
<Check className="w-4 h-4" />
Connected
</span>
) : (
<span className="flex items-center gap-1 text-sm text-red-500">
<X className="w-4 h-4" />
Disconnected
</span>
)}
</div>
<div className="flex gap-2">
{status?.connected ? (
<Button
variant="secondary"
size="sm"
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
>
{disconnectMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2Off className="w-4 h-4" />
)}
Disconnect
</Button>
) : (
<Button
size="sm"
onClick={() => connectMutation.mutate()}
disabled={connectMutation.isPending || !localUrl}
>
{connectMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2 className="w-4 h-4" />
)}
Connect
</Button>
)}
</div>
</div>
{/* Error display */}
{connectMutation.isError && (
<div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(connectMutation.error as Error).message}
<p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
{t('settings.trackingModeBuiltInDesc')}
</p>
{!localEnabled && (
<div className="flex items-center gap-1 mt-2">
<Check className="w-3 h-3 text-bambu-green" />
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
</div>
)}
</button>
{/* Manual sync section */}
{status?.connected && (
<div className="space-y-3">
<div>
<p className="text-sm text-white">Sync AMS Data</p>
<p className="text-xs text-bambu-gray">
Manually sync printer AMS data to Spoolman
{/* Spoolman */}
<button
type="button"
onClick={() => setLocalEnabled(true)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
localEnabled
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
Spoolman
</span>
</div>
<p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
{t('settings.trackingModeSpoolmanDesc')}
</p>
{localEnabled && (
<div className="flex items-center gap-1 mt-2">
<Check className="w-3 h-3 text-bambu-green" />
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
</div>
)}
</button>
</div>
{/* Built-in Inventory details */}
{!localEnabled && (
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
<div className="text-xs text-bambu-gray">
<ul className="list-disc list-inside space-y-0.5">
<li>{t('settings.builtInFeatureRfid')}</li>
<li>{t('settings.builtInFeatureUsage')}</li>
<li>{t('settings.builtInFeatureCatalog')}</li>
</ul>
</div>
</div>
</div>
)}
{/* Spoolman settings - only shown when Spoolman mode is selected */}
{localEnabled && (
<div className="space-y-4">
{/* Info banner about sync requirements */}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-blue-300">
<p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
<ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
<li>{t('settings.syncInfoRfidOnly')}</li>
<li>{t('settings.syncInfoAutoCreate')}</li>
<li>{t('settings.syncInfoThirdPartySkipped')}</li>
</ul>
<p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
<p className="text-blue-300/80">
{t('settings.linkingExistingSpoolsDesc')}
</p>
</div>
</div>
</div>
{/* URL input */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.spoolmanUrl')}
</label>
<input
type="text"
placeholder="http://192.168.1.100:7912"
value={localUrl}
onChange={(e) => setLocalUrl(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.spoolmanUrlHint')}
</p>
</div>
{/* Sync mode */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.syncMode')}
</label>
<select
value={localSyncMode}
onChange={(e) => setLocalSyncMode(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="auto">{t('settings.syncModeAuto')}</option>
<option value="manual">{t('settings.syncModeManual')}</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
{localSyncMode === 'auto'
? t('settings.syncModeAutoDesc')
: t('settings.syncModeManualDesc')}
</p>
</div>
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
{localSyncMode === 'auto' && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.disableWeightSyncDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localDisableWeightSync}
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
{localDisableWeightSync && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.reportPartialUsageDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localReportPartialUsage}
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{/* Connection status */}
<div className="pt-2 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{/* Printer selector */}
<div className="relative flex-1">
<select
value={selectedPrinterId}
onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
<span className="text-sm text-bambu-gray">{t('settings.status')}:</span>
{statusLoading ? (
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
) : status?.connected ? (
<span className="flex items-center gap-1 text-sm text-green-500">
<Check className="w-4 h-4" />
{t('settings.spoolmanConnected')}
</span>
) : (
<span className="flex items-center gap-1 text-sm text-red-500">
<X className="w-4 h-4" />
{t('settings.spoolmanDisconnected')}
</span>
)}
</div>
<div className="flex gap-2">
{status?.connected ? (
<Button
variant="secondary"
size="sm"
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
>
<option value="all">All Printers</option>
{printers?.map((printer: Printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
{/* Sync button */}
<Button
variant="secondary"
size="sm"
onClick={handleSync}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Sync
</Button>
{disconnectMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2Off className="w-4 h-4" />
)}
{t('settings.disconnect')}
</Button>
) : (
<Button
size="sm"
onClick={() => connectMutation.mutate()}
disabled={connectMutation.isPending || !localUrl}
>
{connectMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2 className="w-4 h-4" />
)}
{t('settings.connect')}
</Button>
)}
</div>
</div>
)}
{/* Sync result */}
{syncSuccess && syncResult && (
<div className="mt-3 space-y-2">
{/* Main result */}
<div
className={`p-2 rounded text-sm ${
syncResult.success
? 'bg-green-500/20 border border-green-500/50 text-green-400'
: 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
}`}
>
{syncResult.success
? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
{/* Error display */}
{connectMutation.isError && (
<div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(connectMutation.error as Error).message}
</div>
)}
{/* Skipped spools */}
{syncResult.skipped_count > 0 && (
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
<div className="flex items-center justify-between text-amber-400 mb-1">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-medium">
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
</span>
</div>
{syncResult.skipped_count > 5 && (
<button
onClick={() => setShowAllSkipped(!showAllSkipped)}
className="text-xs text-amber-400 hover:text-amber-300 underline"
>
{showAllSkipped ? 'Show less' : 'Show all'}
</button>
)}
{/* Manual sync section */}
{status?.connected && (
<div className="space-y-3">
<div>
<p className="text-sm text-white">{t('settings.syncAmsData')}</p>
<p className="text-xs text-bambu-gray">
{t('settings.syncAmsDataDesc')}
</p>
</div>
<div className="flex items-center gap-2">
{/* Printer selector */}
<div className="relative flex-1">
<select
value={selectedPrinterId}
onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="all">{t('settings.allPrinters')}</option>
{printers?.map((printer: Printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<ul className="text-xs text-amber-300/80 space-y-0.5">
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
<li key={i} className="flex items-center gap-2">
{s.color && (
<span
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: `#${s.color}` }}
/>
)}
<span>{s.location}</span>
<span className="text-amber-300/60">- {s.reason}</span>
</li>
))}
{!showAllSkipped && syncResult.skipped_count > 5 && (
<li className="text-amber-300/60 italic">
...and {syncResult.skipped_count - 5} more
</li>
{/* Sync button */}
<Button
variant="secondary"
size="sm"
onClick={handleSync}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</ul>
{t('spoolman.sync')}
</Button>
</div>
)}
</div>
)}
{/* Errors */}
{syncResult.errors.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
<div className="text-red-400 font-medium mb-1">Errors:</div>
<ul className="text-xs text-red-300/80 space-y-0.5">
{syncResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
{/* Sync result */}
{syncSuccess && syncResult && (
<div className="mt-3 space-y-2">
{/* Main result */}
<div
className={`p-2 rounded text-sm ${
syncResult.success
? 'bg-green-500/20 border border-green-500/50 text-green-400'
: 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
}`}
>
{syncResult.success
? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
</div>
)}
</div>
)}
{/* Skipped spools */}
{syncResult.skipped_count > 0 && (
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
<div className="flex items-center justify-between text-amber-400 mb-1">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-medium">
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
</span>
</div>
{syncResult.skipped_count > 5 && (
<button
onClick={() => setShowAllSkipped(!showAllSkipped)}
className="text-xs text-amber-400 hover:text-amber-300 underline"
>
{showAllSkipped ? 'Show less' : 'Show all'}
</button>
)}
</div>
<ul className="text-xs text-amber-300/80 space-y-0.5">
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
<li key={i} className="flex items-center gap-2">
{s.color && (
<span
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: `#${s.color}` }}
/>
)}
<span>{s.location}</span>
<span className="text-amber-300/60">- {s.reason}</span>
</li>
))}
{!showAllSkipped && syncResult.skipped_count > 5 && (
<li className="text-amber-300/60 italic">
...and {syncResult.skipped_count - 5} more
</li>
)}
</ul>
</div>
)}
{/* Errors */}
{syncResult.errors.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
<div className="text-red-400 font-medium mb-1">Errors:</div>
<ul className="text-xs text-red-300/80 space-y-0.5">
{syncResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
)}
</CardContent>
@@ -0,0 +1,154 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AdditionalSectionProps } from './types';
function SpoolWeightPicker({
catalog,
value,
onChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
return catalog.filter(e =>
e.name.toLowerCase().includes(s) ||
e.weight.toString().includes(s),
);
}, [catalog, search]);
// Display value: show catalog name if selected, or the weight
const displayValue = useMemo(() => {
if (isOpen) return search;
if (selectedId) {
const entry = catalog.find(e => e.id === selectedId);
if (entry) return entry.name;
}
const match = catalog.find(e => e.weight === value);
if (match) return match.name;
return '';
}, [isOpen, search, selectedId, catalog, value]);
return (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
<span className="flex items-center gap-2">
<Scale className="w-3.5 h-3.5 text-bambu-gray" />
{t('inventory.coreWeight')}
</span>
</label>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0 relative" ref={dropdownRef}>
<input
ref={inputRef}
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchSpoolWeight')}
value={displayValue}
onFocus={() => {
setIsOpen(true);
setSearch('');
}}
onChange={(e) => {
setSearch(e.target.value);
setIsOpen(true);
}}
/>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filtered.map(entry => (
<button
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(selectedId ? entry.id === selectedId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
setSelectedId(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
}}
>
<span className="truncate">{entry.name}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
</button>
))
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<input
type="number"
className="w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green"
value={value}
min={0}
max={2000}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0) onChange(val);
}}
/>
<span className="text-bambu-gray text-sm">g</span>
</div>
</div>
</div>
);
}
export function AdditionalSection({
formData,
updateField,
spoolCatalog,
}: AdditionalSectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Empty Spool Weight */}
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
/>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
<textarea
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
placeholder={t('inventory.notePlaceholder')}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
/>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useState, useMemo } from 'react';
import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ColorSectionProps } from './types';
import { QUICK_COLORS, ALL_COLORS } from './constants';
export function ColorSection({
formData,
updateField,
recentColors,
onColorUsed,
}: ColorSectionProps) {
const { t } = useTranslation();
const [showAllColors, setShowAllColors] = useState(false);
const [colorSearch, setColorSearch] = useState('');
// Current hex without # prefix
const currentHex = formData.rgba.replace('#', '').substring(0, 6);
const isSelected = (hex: string) => {
return currentHex.toUpperCase() === hex.toUpperCase();
};
const selectColor = (hex: string, name: string) => {
// Store as RRGGBBAA (with FF alpha)
updateField('rgba', hex.toUpperCase() + 'FF');
updateField('color_name', name);
onColorUsed({ name, hex });
};
// Colors to show based on search/expand state
const filteredColors = useMemo(() => {
if (colorSearch) {
return ALL_COLORS.filter(c =>
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
);
}
return showAllColors ? ALL_COLORS : QUICK_COLORS;
}, [colorSearch, showAllColors]);
return (
<div className="space-y-3">
{/* Color preview banner */}
<div
className="h-10 rounded-lg border border-bambu-dark-tertiary"
style={{ backgroundColor: `#${currentHex}` }}
/>
{/* Recently Used Colors */}
{recentColors.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-bambu-gray shrink-0">
<Clock className="w-3 h-3" />
<span>{t('inventory.recentColors')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{recentColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Color Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
<input
type="text"
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchColors')}
value={colorSearch}
onChange={(e) => setColorSearch(e.target.value)}
/>
</div>
{/* Color Swatches Grid */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-bambu-gray">
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
{!colorSearch && (
<button
type="button"
onClick={() => setShowAllColors(!showAllColors)}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{showAllColors ? (
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
) : (
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{filteredColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
{/* Manual Color Input */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.colorName')}</label>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.colorNamePlaceholder')}
value={formData.color_name}
onChange={(e) => updateField('color_name', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.hexColor')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray">#</span>
<input
type="text"
className="w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green"
placeholder="RRGGBB"
value={currentHex.toUpperCase()}
onChange={(e) => {
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
}}
/>
</div>
<input
type="color"
className="w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent"
value={`#${currentHex}`}
onChange={(e) => {
const hex = e.target.value.replace('#', '').toUpperCase();
updateField('rgba', hex + 'FF');
}}
title={t('inventory.pickColor')}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { FilamentSectionProps, FilamentOption } from './types';
import { MATERIALS, KNOWN_VARIANTS } from './constants';
import { parsePresetName } from './utils';
export function FilamentSection({
formData,
updateField,
cloudAuthenticated,
loadingCloudPresets,
presetInputValue,
setPresetInputValue,
selectedPresetOption,
filamentOptions,
availableBrands,
}: FilamentSectionProps) {
const { t } = useTranslation();
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
const [brandSearch, setBrandSearch] = useState('');
const presetRef = useRef<HTMLDivElement>(null);
const brandRef = useRef<HTMLDivElement>(null);
// Close dropdowns on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
setPresetDropdownOpen(false);
}
if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
setBrandDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Filtered presets based on search
const filteredPresets = useMemo(() => {
if (!presetInputValue) return filamentOptions;
const search = presetInputValue.toLowerCase();
return filamentOptions.filter(o =>
o.displayName.toLowerCase().includes(search) ||
o.code.toLowerCase().includes(search),
);
}, [filamentOptions, presetInputValue]);
// Filtered brands
const filteredBrands = useMemo(() => {
if (!brandSearch) return availableBrands;
const search = brandSearch.toLowerCase();
return availableBrands.filter(b => b.toLowerCase().includes(search));
}, [availableBrands, brandSearch]);
// Handle preset selection
const handlePresetSelect = (option: FilamentOption) => {
updateField('slicer_filament', option.code);
setPresetInputValue(option.displayName);
setPresetDropdownOpen(false);
// Auto-fill material, brand, subtype from preset name
const parsed = parsePresetName(option.name);
if (parsed.material) updateField('material', parsed.material);
if (parsed.brand) updateField('brand', parsed.brand);
if (parsed.variant) updateField('subtype', parsed.variant);
};
return (
<div className="space-y-4">
{/* Cloud status indicator */}
<div className="flex items-center gap-2 text-xs text-bambu-gray">
{loadingCloudPresets ? (
<><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
) : cloudAuthenticated ? (
<><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
) : (
<><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
)}
</div>
{/* Slicer Preset (autocomplete) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('inventory.slicerPreset')} *
</label>
<div className="relative" ref={presetRef}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
<input
type="text"
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchPresets')}
value={presetInputValue}
onChange={(e) => {
setPresetInputValue(e.target.value);
setPresetDropdownOpen(true);
}}
onFocus={() => {
setPresetDropdownOpen(true);
setPresetInputValue('');
}}
/>
{presetDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
{filteredPresets.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
) : (
filteredPresets.map(option => (
<button
key={option.code}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
selectedPresetOption?.code === option.code
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => handlePresetSelect(option)}
>
<span className="truncate">{option.displayName}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
</button>
))
)}
</div>
)}
</div>
{selectedPresetOption && (
<div className="mt-1 text-xs text-bambu-gray">
{t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
</div>
)}
</div>
{/* Material */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
<select
value={formData.material}
onChange={(e) => updateField('material', e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value="">{t('inventory.selectMaterial')}</option>
{MATERIALS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
{/* Brand (dropdown with search) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
<div className="relative" ref={brandRef}>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchBrand')}
value={brandDropdownOpen ? brandSearch : formData.brand}
onChange={(e) => {
setBrandSearch(e.target.value);
setBrandDropdownOpen(true);
}}
onFocus={() => {
setBrandDropdownOpen(true);
setBrandSearch('');
}}
/>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
{brandDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
{filteredBrands.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filteredBrands.map(brand => (
<button
key={brand}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
}`}
onClick={() => {
updateField('brand', brand);
setBrandDropdownOpen(false);
setBrandSearch('');
}}
>
{brand}
</button>
))
)}
{/* Allow custom brand */}
{brandSearch && !filteredBrands.includes(brandSearch) && (
<button
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
onClick={() => {
updateField('brand', brandSearch);
setBrandDropdownOpen(false);
setBrandSearch('');
}}
>
{t('inventory.useCustomBrand', { brand: brandSearch })}
</button>
)}
</div>
)}
</div>
</div>
{/* Variant / Subtype */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
<div className="relative">
<input
type="text"
value={formData.subtype}
onChange={(e) => updateField('subtype', e.target.value)}
list="variant-suggestions"
placeholder="Basic, Matte, Silk..."
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
/>
<datalist id="variant-suggestions">
{KNOWN_VARIANTS.map(v => (
<option key={v} value={v} />
))}
</datalist>
</div>
</div>
{/* Label Weight */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
<div className="relative">
<input
type="number"
value={formData.label_weight}
onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,268 @@
import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { CalibrationProfile, PAProfileSectionProps } from './types';
import { isMatchingCalibration } from './utils';
export function PAProfileSection({
formData,
printersWithCalibrations,
selectedProfiles,
setSelectedProfiles,
expandedPrinters,
setExpandedPrinters,
}: PAProfileSectionProps) {
const { t } = useTranslation();
const togglePrinterExpanded = (printerId: string) => {
setExpandedPrinters((prev) => {
const next = new Set(prev);
if (next.has(printerId)) next.delete(printerId);
else next.add(printerId);
return next;
});
};
const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {
const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;
const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;
setSelectedProfiles((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
// Remove existing profile for same printer/nozzle
for (const existingKey of Array.from(next)) {
const parts = existingKey.split(':');
const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;
if (existingPrinterNozzle === printerNozzleKey) {
next.delete(existingKey);
}
}
next.add(key);
}
return next;
});
};
// Auto-select best matching profiles
const autoSelectProfiles = () => {
const newSelection = new Set<string>();
for (const { printer, calibrations } of printersWithCalibrations) {
if (!printer.connected) continue;
const matchingCals = calibrations.filter(cal =>
isMatchingCalibration(cal, formData),
);
// Group by extruder
const byExtruder = new Map<string, CalibrationProfile[]>();
for (const cal of matchingCals) {
const extKey = `${cal.extruder_id ?? 'null'}`;
if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);
byExtruder.get(extKey)!.push(cal);
}
// Select best (highest K) for each extruder
for (const [extKey, cals] of byExtruder) {
if (cals.length > 0) {
const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);
const best = sorted[0];
newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);
}
}
}
setSelectedProfiles(newSelection);
};
if (!formData.material) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.selectMaterialFirst')}
</p>
</div>
);
}
if (printersWithCalibrations.length === 0) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.noPrintersConfigured')}
</p>
</div>
);
}
// Count total matching profiles
const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {
if (!printer.connected) return sum;
return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;
}, 0);
const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {
const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;
const isSelected = selectedProfiles.has(key);
return (
<label
key={`${cal.cali_idx}-${cal.extruder_id}`}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${
isSelected
? 'bg-bambu-green/10 border-bambu-green/30'
: 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}
className="w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green"
/>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>
{cal.name || cal.filament_id}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
K={cal.k_value.toFixed(3)}
</span>
</div>
</label>
);
};
return (
<div className="space-y-4">
{/* Header with auto-select */}
<div className="flex items-center justify-between">
<p className="text-xs text-bambu-gray">
{t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
</p>
{totalMatching > 0 && (
<button
type="button"
onClick={autoSelectProfiles}
className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
{t('inventory.autoSelect')} ({totalMatching})
</button>
)}
</div>
{/* Printer sections */}
<div className="space-y-3">
{printersWithCalibrations.map(({ printer, calibrations }) => {
const isExpanded = expandedPrinters.has(String(printer.id));
const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
const matchingCount = matchingCals.length;
// Multi-nozzle grouping
const isMultiNozzle = matchingCals.some(cal =>
cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
);
const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
const rightNozzleCals = matchingCals.filter(cal =>
cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
);
return (
<div
key={printer.id}
className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
>
{/* Printer Header */}
<button
type="button"
onClick={() => togglePrinterExpanded(String(printer.id))}
className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-bambu-gray" />
) : (
<ChevronRight className="w-4 h-4 text-bambu-gray" />
)}
<span className="font-medium text-white">
{printer.name}
</span>
{matchingCount > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
{t('inventory.noMatches')}
</span>
)}
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
printer.connected
? 'bg-green-500/20 text-green-500'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{printer.connected ? t('inventory.connected') : t('inventory.offline')}
</span>
</button>
{/* Calibration Profiles */}
{isExpanded && (
<div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
{!printer.connected ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.printerOffline')}
</p>
) : matchingCount === 0 ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.noKProfilesMatch')}
</p>
) : isMultiNozzle ? (
<>
{leftNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.leftNozzle')}
</p>
<div className="space-y-2">
{leftNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
{rightNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.rightNozzle')}
</p>
<div className="space-y-2">
{rightNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
</>
) : (
<div className="space-y-2">
{matchingCals.map(cal => renderProfile(printer, cal))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Summary */}
{selectedProfiles.size > 0 && (
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<p className="text-sm text-white">
<span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import type { ColorPreset } from './types';
// Material options
export const MATERIALS = [
'PLA', 'PETG', 'ABS', 'TPU', 'ASA', 'PC', 'PA', 'PVA', 'HIPS',
'PA-CF', 'PETG-CF', 'PLA-CF',
];
// Common spool weights
export const WEIGHTS = [250, 500, 750, 1000, 2000, 3000];
// Default brand options (will be augmented with cloud presets)
export const DEFAULT_BRANDS = [
'Bambu', 'PolyLite', 'PolyTerra', 'eSUN', 'Overture',
'Fiberon', 'SUNLU', 'Inland', 'Hatchbox', 'Generic',
];
// Known filament variants/subtypes
export const KNOWN_VARIANTS = [
'Basic', 'Matte', 'Silk', 'Tough', 'HF', 'High Flow', 'Engineering',
'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',
'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',
'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',
'Heat Resistant', 'UV Resistant', 'ESD', 'Conductive', 'Magnetic',
'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',
];
// Quick color swatches - most common colors (shown by default)
export const QUICK_COLORS: ColorPreset[] = [
{ name: 'Black', hex: '000000' },
{ name: 'White', hex: 'FFFFFF' },
{ name: 'Gray', hex: '808080' },
{ name: 'Red', hex: 'FF0000' },
{ name: 'Orange', hex: 'FFA500' },
{ name: 'Yellow', hex: 'FFFF00' },
{ name: 'Green', hex: '00AE42' },
{ name: 'Blue', hex: '0066FF' },
{ name: 'Purple', hex: '8B00FF' },
{ name: 'Pink', hex: 'FF69B4' },
{ name: 'Brown', hex: '8B4513' },
{ name: 'Silver', hex: 'C0C0C0' },
];
// Extended color palette (shown when expanded)
export const EXTENDED_COLORS: ColorPreset[] = [
// Reds
{ name: 'Dark Red', hex: '8B0000' },
{ name: 'Crimson', hex: 'DC143C' },
{ name: 'Coral', hex: 'FF7F50' },
{ name: 'Salmon', hex: 'FA8072' },
// Oranges
{ name: 'Dark Orange', hex: 'FF8C00' },
{ name: 'Peach', hex: 'FFDAB9' },
// Yellows
{ name: 'Gold', hex: 'FFD700' },
{ name: 'Khaki', hex: 'F0E68C' },
{ name: 'Lemon', hex: 'FFF44F' },
// Greens
{ name: 'Lime', hex: '32CD32' },
{ name: 'Forest Green', hex: '228B22' },
{ name: 'Olive', hex: '808000' },
{ name: 'Mint', hex: '98FF98' },
{ name: 'Teal', hex: '008080' },
// Blues
{ name: 'Navy', hex: '000080' },
{ name: 'Sky Blue', hex: '87CEEB' },
{ name: 'Royal Blue', hex: '4169E1' },
{ name: 'Cyan', hex: '00FFFF' },
{ name: 'Turquoise', hex: '40E0D0' },
// Purples
{ name: 'Violet', hex: 'EE82EE' },
{ name: 'Magenta', hex: 'FF00FF' },
{ name: 'Indigo', hex: '4B0082' },
{ name: 'Lavender', hex: 'E6E6FA' },
{ name: 'Plum', hex: 'DDA0DD' },
// Pinks
{ name: 'Hot Pink', hex: 'FF69B4' },
{ name: 'Rose', hex: 'FF007F' },
{ name: 'Blush', hex: 'FFB6C1' },
// Browns
{ name: 'Chocolate', hex: 'D2691E' },
{ name: 'Tan', hex: 'D2B48C' },
{ name: 'Beige', hex: 'F5F5DC' },
{ name: 'Maroon', hex: '800000' },
// Neutrals
{ name: 'Dark Gray', hex: '404040' },
{ name: 'Light Gray', hex: 'D3D3D3' },
{ name: 'Charcoal', hex: '36454F' },
{ name: 'Ivory', hex: 'FFFFF0' },
// Bambu specific
{ name: 'Bambu Green', hex: '00AE42' },
{ name: 'Jade White', hex: 'E8E8E8' },
{ name: 'Titan Gray', hex: '5A5A5A' },
];
// All colors combined
export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
// Local storage keys
export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
export const MAX_RECENT_COLORS = 8;
+124
View File
@@ -0,0 +1,124 @@
import type { Printer, SpoolKProfile } from '../../api/client';
// Form data structure
export interface SpoolFormData {
material: string;
subtype: string;
brand: string;
color_name: string;
rgba: string;
label_weight: number;
core_weight: number;
slicer_filament: string;
note: string;
}
export const defaultFormData: SpoolFormData = {
material: '',
subtype: '',
brand: '',
color_name: '',
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
slicer_filament: '',
note: '',
};
// Printer with calibrations type
export interface PrinterWithCalibrations {
printer: Printer & { connected?: boolean };
calibrations: CalibrationProfile[];
}
// Calibration profile from printer status
export interface CalibrationProfile {
cali_idx: number;
filament_id: string;
setting_id: string;
name: string;
k_value: number;
n_coef: number;
extruder_id?: number | null;
nozzle_diameter?: string;
}
// Filament option from presets
export interface FilamentOption {
code: string;
name: string;
displayName: string;
isCustom: boolean;
allCodes: string[];
}
// Color preset
export interface ColorPreset {
name: string;
hex: string;
}
// Section props base
export interface SectionProps {
formData: SpoolFormData;
updateField: <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => void;
}
// Filament section props
export interface FilamentSectionProps extends SectionProps {
cloudAuthenticated: boolean;
loadingCloudPresets: boolean;
presetInputValue: string;
setPresetInputValue: (value: string) => void;
selectedPresetOption?: FilamentOption;
filamentOptions: FilamentOption[];
availableBrands: string[];
}
// Color section props
export interface ColorSectionProps extends SectionProps {
recentColors: ColorPreset[];
onColorUsed: (color: ColorPreset) => void;
}
// Additional section props
export interface AdditionalSectionProps extends SectionProps {
spoolCatalog: { id: number; name: string; weight: number }[];
}
// PA Profile section props
export interface PAProfileSectionProps extends SectionProps {
printersWithCalibrations: PrinterWithCalibrations[];
selectedProfiles: Set<string>;
setSelectedProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
expandedPrinters: Set<string>;
setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;
}
// Validation result
export interface ValidationResult {
isValid: boolean;
errors: Partial<Record<keyof SpoolFormData, string>>;
}
export function validateForm(formData: SpoolFormData): ValidationResult {
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
if (!formData.slicer_filament) {
errors.slicer_filament = 'Slicer preset is required';
}
if (!formData.material) {
errors.material = 'Material is required';
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Existing K-profile for a spool (from saved data)
export interface SavedKProfile extends SpoolKProfile {
printer_serial?: string;
}
+253
View File
@@ -0,0 +1,253 @@
import type { SlicerSetting } from '../../api/client';
import type { ColorPreset, FilamentOption } from './types';
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
// Fallback filament presets when cloud is not available
const FALLBACK_PRESETS: FilamentOption[] = [
{ code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
{ code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
{ code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
{ code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
{ code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
{ code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
{ code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
{ code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
{ code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
{ code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
{ code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
{ code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
{ code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
{ code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
{ code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
{ code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
{ code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
];
// Parse a slicer preset name to extract brand, material, and variant
export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
// Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
let cleanName = name.replace(/@.*$/, '').trim();
// Remove (Custom) tag
cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
// Remove leading # or * markers
cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
// Materials list - order matters (longer/more specific first)
const materials = [
'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PCTG', 'PEEK', 'PEI',
];
// Find material in the name
let material = '';
let materialIdx = -1;
for (const m of materials) {
const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
if (idx !== -1) {
material = m;
materialIdx = idx;
break;
}
}
// Brand is everything before the material
let brand = '';
if (materialIdx > 0) {
brand = cleanName.substring(0, materialIdx).trim();
brand = brand.replace(/[-_\s]+$/, '');
}
// Everything after material is potential variant
let afterMaterial = '';
if (materialIdx !== -1 && material) {
afterMaterial = cleanName.substring(materialIdx + material.length).trim();
afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
}
// Check for known variant - could be before OR after material
let variant = '';
// First check after material (most common)
for (const v of KNOWN_VARIANTS) {
if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
variant = v;
break;
}
}
// If no variant found after material, check if brand contains a known variant
if (!variant && brand) {
for (const v of KNOWN_VARIANTS) {
const variantPattern = new RegExp(`\\s+${v}$`, 'i');
if (variantPattern.test(brand)) {
variant = v;
brand = brand.replace(variantPattern, '').trim();
break;
}
}
}
return { brand, material, variant };
}
// Extract unique brands from cloud presets
export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
const brandSet = new Set<string>(DEFAULT_BRANDS);
for (const preset of presets) {
const { brand } = parsePresetName(preset.name);
if (brand && brand.length > 1) {
brandSet.add(brand);
}
}
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
}
// Build filament options from cloud presets
export function buildFilamentOptions(
cloudPresets: SlicerSetting[],
configuredPrinterModels: Set<string>,
): FilamentOption[] {
if (cloudPresets.length > 0) {
const customPresets: FilamentOption[] = [];
const defaultPresetsMap = new Map<string, FilamentOption>();
for (const preset of cloudPresets) {
if (preset.is_custom) {
// Custom presets: include if matches configured printers or no printer filter
const presetNameUpper = preset.name.toUpperCase();
const matchesPrinter = configuredPrinterModels.size === 0 ||
Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
!presetNameUpper.includes('@');
if (matchesPrinter) {
customPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: `${preset.name} (Custom)`,
isCustom: true,
allCodes: [preset.setting_id],
});
}
} else {
// Default presets: deduplicate by base name
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = defaultPresetsMap.get(baseName);
if (existing) {
existing.allCodes.push(preset.setting_id);
} else {
defaultPresetsMap.set(baseName, {
code: preset.setting_id,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [preset.setting_id],
});
}
}
}
return [
...customPresets,
...Array.from(defaultPresetsMap.values()),
].sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Fallback to hardcoded defaults
return FALLBACK_PRESETS;
}
// Find selected preset option
export function findPresetOption(
slicerFilament: string,
filamentOptions: FilamentOption[],
): FilamentOption | undefined {
if (!slicerFilament) return undefined;
// First try exact match on primary code
let option = filamentOptions.find(o => o.code === slicerFilament);
if (!option) {
// Try matching against any code in allCodes
option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
}
if (!option) {
// Try case-insensitive match
const slicerLower = slicerFilament.toLowerCase();
option = filamentOptions.find(o =>
o.code.toLowerCase() === slicerLower ||
o.allCodes.some(c => c.toLowerCase() === slicerLower),
);
}
return option;
}
// Recent colors management
export function loadRecentColors(): ColorPreset[] {
try {
const stored = localStorage.getItem(RECENT_COLORS_KEY);
if (stored) {
return JSON.parse(stored) as ColorPreset[];
}
} catch {
// Ignore errors
}
return [];
}
export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
const filtered = currentRecent.filter(
c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
);
const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
try {
localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
} catch {
// Ignore errors
}
return updated;
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
formData: { material: string; brand: string; subtype: string },
): boolean {
if (!formData.material) return false;
const profileName = cal.name || '';
// Remove flow type prefixes
const cleanName = profileName
.replace(/^High Flow[_\s]+/i, '')
.replace(/^Standard[_\s]+/i, '')
.replace(/^HF[_\s]+/i, '')
.replace(/^S[_\s]+/i, '')
.trim();
const parsed = parsePresetName(cleanName);
// Match material (required)
const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
if (!materialMatch) return false;
// Match brand if specified in form
if (formData.brand) {
const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
if (!brandMatch) return false;
}
// Match variant/subtype if specified in form
if (formData.subtype) {
const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
if (!variantMatch) return false;
}
return true;
}
+24
View File
@@ -226,6 +226,30 @@ export function useWebSocket() {
}
}));
break;
case 'spool_auto_assigned':
// RFID tag matched - refresh inventory data
debouncedInvalidate('inventory-spools');
debouncedInvalidate('inventory-assignments');
break;
case 'spool_usage_logged':
// Filament consumption recorded - refresh spool data
debouncedInvalidate('inventory-spools');
break;
case 'unknown_tag':
// Unknown RFID tag detected - dispatch event for UI
window.dispatchEvent(new CustomEvent('unknown-tag', {
detail: {
printer_id: (message as unknown as { printer_id?: number }).printer_id,
ams_id: (message as unknown as { ams_id?: number }).ams_id,
tray_id: (message as unknown as { tray_id?: number }).tray_id,
tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
}
}));
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
+243 -2
View File
@@ -8,6 +8,7 @@ export default {
profiles: 'Profile',
maintenance: 'Wartung',
projects: 'Projekte',
inventory: 'Inventar',
files: 'Dateimanager',
settings: 'Einstellungen',
system: 'System',
@@ -74,6 +75,8 @@ export default {
dismiss: 'Schließen',
apply: 'Anwenden',
reset: 'Zurücksetzen',
export: 'Exportieren',
import: 'Importieren',
clear: 'Leeren',
selectAll: 'Alle auswählen',
deselectAll: 'Auswahl aufheben',
@@ -1142,11 +1145,37 @@ export default {
turnOn: 'Einschalten',
turnOff: 'Ausschalten',
},
// Spoolman
spoolmanEnabled: 'Spoolman-Integration aktivieren',
// Filament Tracking Mode
filamentTracking: 'Filament-Verfolgung',
filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
trackingModeBuiltIn: 'Integriertes Inventar',
trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',
builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
// Spoolman settings
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
spoolmanConnected: 'Verbunden',
spoolmanDisconnected: 'Nicht verbunden',
status: 'Status',
connect: 'Verbinden',
disconnect: 'Trennen',
howSyncWorks: 'So funktioniert die Synchronisierung',
syncInfoRfidOnly: 'Nur offizielle Bambu Lab Spulen mit RFID werden synchronisiert',
syncInfoAutoCreate: 'Neue Spulen werden bei der ersten Synchronisierung automatisch in Spoolman erstellt',
syncInfoThirdPartySkipped: 'Nicht-Bambu-Lab-Spulen (Drittanbieter, nachgefüllt) werden übersprungen',
linkingExistingSpools: 'Vorhandene Spulen verknüpfen',
linkingExistingSpoolsDesc: 'Um vorhandene Spoolman-Spulen mit Ihrem AMS zu verknüpfen, fahren Sie über einen AMS-Slot und klicken Sie auf "Mit Spoolman verknüpfen".',
syncMode: 'Synchronisierungsmodus',
syncModeAuto: 'Automatisch',
syncModeManual: 'Nur manuell',
syncModeAutoDesc: 'AMS-Daten werden automatisch synchronisiert, wenn Änderungen erkannt werden',
syncModeManualDesc: 'Nur bei manueller Auslösung synchronisieren',
syncAmsData: 'AMS-Daten synchronisieren',
syncAmsDataDesc: 'AMS-Daten des Druckers manuell mit Spoolman synchronisieren',
allPrinters: 'Alle Drucker',
// Default printer
noDefaultPrinter: 'Kein Standard (jedes Mal fragen)',
// Sidebar
@@ -1376,6 +1405,77 @@ export default {
cameraConnected: 'Kamera verbunden{{resolution}}',
},
testConnection: 'Verbindung testen',
catalog: {
spoolCatalog: 'Spulenkatalog',
spoolCatalogDescription: 'Leerspulengewichte nach Marke/Typ. Wird für die automatische Gewichtssuche beim Hinzufügen von Spulen verwendet.',
searchCatalog: 'Katalog durchsuchen...',
addNewEntry: 'Neuen Eintrag hinzufügen',
namePlaceholder: 'Name (z.B. Bambu Lab - Plastik)',
weight: 'Gewicht',
type: 'Typ',
default: 'Standard',
custom: 'Benutzerdefiniert',
noMatch: 'Keine Einträge entsprechen Ihrer Suche',
empty: 'Keine Einträge im Katalog',
deleteEntry: 'Eintrag löschen',
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
resetCatalog: 'Katalog zurücksetzen',
resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Einträge werden entfernt.',
loadFailed: 'Spulenkatalog konnte nicht geladen werden',
nameWeightRequired: 'Name und Gewicht sind erforderlich',
entryAdded: 'Eintrag hinzugefügt',
addFailed: 'Eintrag konnte nicht hinzugefügt werden',
entryUpdated: 'Eintrag aktualisiert',
updateFailed: 'Eintrag konnte nicht aktualisiert werden',
entryDeleted: 'Eintrag gelöscht',
deleteFailed: 'Eintrag konnte nicht gelöscht werden',
resetSuccess: 'Katalog auf Standardwerte zurückgesetzt',
resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
exported: '{{count}} Einträge exportiert',
imported: '{{added}} Einträge importiert ({{skipped}} übersprungen)',
importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
exportTooltip: 'Katalog als JSON exportieren',
importTooltip: 'Katalog aus JSON importieren',
resetTooltip: 'Auf Standardwerte zurücksetzen',
},
colorCatalog: {
title: 'Farbkatalog',
description: 'Filamentfarben nach Hersteller/Material. Wird für die automatische Farbsuche beim Hinzufügen von Spulen verwendet.',
searchColors: 'Farben durchsuchen...',
allManufacturers: 'Alle Hersteller',
addNewColor: 'Neue Farbe hinzufügen',
manufacturer: 'Hersteller',
colorName: 'Farbname',
hex: 'Hex',
materialOptional: 'Material (optional)',
showing: '{{filtered}} von {{total}} Farben angezeigt',
noMatch: 'Keine Farben entsprechen Ihrer Suche',
empty: 'Keine Farben im Katalog',
deleteColor: 'Farbe löschen',
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
resetCatalog: 'Farbkatalog zurücksetzen',
resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.',
sync: 'Sync',
starting: 'Starten...',
syncTooltip: 'Von FilamentColors.xyz synchronisieren (2000+ Farben)',
loadFailed: 'Farbkatalog konnte nicht geladen werden',
fieldsRequired: 'Hersteller, Farbname und Hex-Farbe sind erforderlich',
colorAdded: 'Farbe hinzugefügt',
addFailed: 'Farbe konnte nicht hinzugefügt werden',
colorUpdated: 'Farbe aktualisiert',
updateFailed: 'Farbe konnte nicht aktualisiert werden',
colorDeleted: 'Farbe gelöscht',
deleteFailed: 'Farbe konnte nicht gelöscht werden',
resetSuccess: 'Farbkatalog auf Standardwerte zurückgesetzt',
resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
syncUpToDate: 'Bereits aktuell ({{count}} Farben geprüft)',
syncComplete: '{{added}} neue Farben hinzugefügt ({{skipped}} bereits vorhanden)',
syncError: 'Sync-Fehler',
syncFailed: 'Synchronisierung von FilamentColors.xyz fehlgeschlagen',
exported: '{{count}} Farben exportiert',
imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',
importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
},
},
// Notifications (for push notifications)
@@ -2327,6 +2427,146 @@ export default {
reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',
},
// Inventar
inventory: {
title: 'Spulen-Inventar',
addSpool: 'Spule hinzufügen',
editSpool: 'Spule bearbeiten',
material: 'Material',
selectMaterial: 'Material auswählen...',
subtype: 'Untertyp',
brand: 'Marke',
searchBrand: 'Marke suchen...',
useCustomBrand: '"{{brand}}" verwenden',
colorName: 'Farbname',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: 'Farbe',
hexColor: 'Hex-Farbe',
pickColor: 'Benutzerdefinierte Farbe wählen',
labelWeight: 'Nenngewicht',
coreWeight: 'Leergewicht der Spule',
searchSpoolWeight: 'Spulengewicht suchen...',
weightUsed: 'Verbraucht',
slicerFilament: 'Slicer-Filament-ID',
slicerFilamentName: 'Slicer-Preset-Name',
slicerPreset: 'Slicer-Preset',
searchPresets: 'Filament-Presets suchen...',
selectedPreset: 'Ausgewählt',
noPresetsFound: 'Keine Presets gefunden',
tempOverrides: 'Temperatur-Überschreibungen',
note: 'Notiz',
notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',
archive: 'Archivieren',
restore: 'Wiederherstellen',
noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
kProfiles: 'K-Profile',
addKProfile: 'K-Profil hinzufügen',
assignSpool: 'Spule zuweisen',
unassignSpool: 'Zuweisung aufheben',
assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',
assignFailed: 'Spulenzuweisung fehlgeschlagen',
selectSpool: 'Wählen Sie eine Spule für diesen Slot',
assigned: 'Zugewiesen',
assigning: 'Wird zugewiesen...',
searchSpools: 'Spulen suchen...',
allMaterials: 'Alle Materialien',
filterByBrand: 'Nach Marke filtern...',
showArchived: 'Archivierte anzeigen',
spoolCreated: 'Spule erstellt',
spoolUpdated: 'Spule aktualisiert',
spoolDeleted: 'Spule gelöscht',
spoolArchived: 'Spule archiviert',
spoolRestored: 'Spule wiederhergestellt',
deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
advancedSettings: 'Erweiterte Einstellungen',
filamentInfoTab: 'Filament-Info',
paProfileTab: 'PA-Profil',
filamentInfo: 'Filament',
additional: 'Zusätzlich',
loadingPresets: 'Cloud-Presets werden geladen...',
cloudConnected: 'Cloud verbunden',
cloudNotConnected: 'Cloud nicht verbunden (Standardwerte)',
recentColors: 'Zuletzt',
searchColors: 'Farben suchen...',
searchResults: 'Suchergebnisse',
allColors: 'Alle Farben',
commonColors: 'Häufige Farben',
showLess: 'Weniger',
showAll: 'Alle',
noColorsFound: 'Keine Farben gefunden',
noResults: 'Keine Ergebnisse',
selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
matchingFilter: 'Filter',
anyBrand: 'Jede Marke',
anyVariant: 'Jede Variante',
autoSelect: 'Auto-Auswahl',
matches: 'Treffer',
match: 'Treffer',
noMatches: 'Keine Treffer',
connected: 'Verbunden',
offline: 'Offline',
printerOffline: 'Drucker ist offline. Verbinden Sie ihn, um Kalibrierungsprofile anzuzeigen.',
noKProfilesMatch: 'Keine K-Profile stimmen mit dem gewählten Filament überein.',
leftNozzle: 'Linke Düse',
rightNozzle: 'Rechte Düse',
profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',
// Stats & enhanced table
totalInventory: 'Gesamtbestand',
totalConsumed: 'Gesamtverbrauch',
byMaterial: 'Nach Material',
inPrinter: 'Im Drucker',
lowStock: 'Niedriger Bestand',
sinceTracking: 'Seit Beginn der Erfassung',
loadedInAms: 'Im AMS/Ext geladen',
remaining: 'Verbleibend',
lowStockThreshold: '<20% verbleibend',
search: 'Spulen suchen...',
showing: 'Zeige',
to: 'bis',
of: 'von',
show: 'Zeige',
spools: 'Spulen',
spool: 'Spule',
page: 'Seite',
noSpoolsMatch: 'Keine Ergebnisse',
noSpoolsMatchDesc: 'Versuchen Sie, Ihre Suche oder Filter anzupassen.',
active: 'Aktiv',
archived: 'Archiviert',
all: 'Alle',
used: 'Verwendet',
new: 'Neu',
clearFilters: 'Filter löschen',
table: 'Tabelle',
cards: 'Karten',
net: 'Netto',
// Column config
columns: 'Spalten',
configureColumns: 'Spalten konfigurieren',
configureColumnsDesc: 'Ziehen zum Neuordnen oder Pfeile verwenden. Sichtbarkeit mit dem Augensymbol umschalten.',
visible: 'sichtbar',
reset: 'Zurücksetzen',
cancel: 'Abbrechen',
applyChanges: 'Änderungen anwenden',
moveUp: 'Nach oben',
moveDown: 'Nach unten',
hideColumn: 'Spalte ausblenden',
showColumn: 'Spalte einblenden',
// Tag-Verknüpfung
linkToSpool: 'Mit Spule verknüpfen',
tagLinked: 'Tag mit Spule verknüpft',
tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',
tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',
unknownTag: 'Unbekannter RFID-Tag erkannt',
// Verbrauchshistorie
usageHistory: 'Verbrauchshistorie',
noUsageHistory: 'Noch kein Verbrauch erfasst',
printName: 'Druckname',
weightConsumed: 'Verbrauchtes Gewicht',
clearHistory: 'Löschen',
historyCleared: 'Verbrauchshistorie gelöscht',
},
// Timelapse
timelapse: {
title: 'Zeitraffer',
@@ -2944,6 +3184,7 @@ export default {
noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
custom: 'Benutzerdefiniert',
builtin: 'Integriert',
settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
filamentProfile: 'Filamentprofil',
},
+247 -2
View File
@@ -8,6 +8,7 @@ export default {
profiles: 'Profiles',
maintenance: 'Maintenance',
projects: 'Projects',
inventory: 'Inventory',
files: 'File Manager',
settings: 'Settings',
system: 'System',
@@ -74,6 +75,8 @@ export default {
dismiss: 'Dismiss',
apply: 'Apply',
reset: 'Reset',
export: 'Export',
import: 'Import',
clear: 'Clear',
selectAll: 'Select All',
deselectAll: 'Deselect All',
@@ -1142,11 +1145,37 @@ export default {
turnOn: 'Turn On',
turnOff: 'Turn Off',
},
// Spoolman
spoolmanEnabled: 'Enable Spoolman Integration',
// Filament Tracking Mode
filamentTracking: 'Filament Tracking',
filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
trackingModeBuiltIn: 'Built-in Inventory',
trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
trackingModeSpoolmanDesc: 'External filament management server',
builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',
builtInFeatureUsage: 'Tracks filament consumption per print',
builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
// Spoolman settings
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
spoolmanConnected: 'Connected',
spoolmanDisconnected: 'Disconnected',
status: 'Status',
connect: 'Connect',
disconnect: 'Disconnect',
howSyncWorks: 'How Sync Works',
syncInfoRfidOnly: 'Only official Bambu Lab spools with RFID are synced',
syncInfoAutoCreate: 'New spools are auto-created in Spoolman on first sync',
syncInfoThirdPartySkipped: 'Non-Bambu Lab spools (third-party, refilled) are skipped',
linkingExistingSpools: 'Linking Existing Spools',
linkingExistingSpoolsDesc: 'To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".',
syncMode: 'Sync Mode',
syncModeAuto: 'Automatic',
syncModeManual: 'Manual Only',
syncModeAutoDesc: 'AMS data syncs automatically when changes are detected',
syncModeManualDesc: 'Only sync when manually triggered',
syncAmsData: 'Sync AMS Data',
syncAmsDataDesc: 'Manually sync printer AMS data to Spoolman',
allPrinters: 'All Printers',
// Default printer
noDefaultPrinter: 'No default (ask each time)',
// Sidebar
@@ -1376,6 +1405,77 @@ export default {
cameraConnected: 'Camera connected{{resolution}}',
},
testConnection: 'Test Connection',
catalog: {
spoolCatalog: 'Spool Catalog',
spoolCatalogDescription: 'Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.',
searchCatalog: 'Search catalog...',
addNewEntry: 'Add New Entry',
namePlaceholder: 'Name (e.g., Bambu Lab - Plastic)',
weight: 'Weight',
type: 'Type',
default: 'Default',
custom: 'Custom',
noMatch: 'No entries match your search',
empty: 'No entries in catalog',
deleteEntry: 'Delete Entry',
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
resetCatalog: 'Reset Catalog',
resetConfirm: 'Reset catalog to defaults? This will remove all custom entries.',
loadFailed: 'Failed to load spool catalog',
nameWeightRequired: 'Name and weight are required',
entryAdded: 'Entry added',
addFailed: 'Failed to add entry',
entryUpdated: 'Entry updated',
updateFailed: 'Failed to update entry',
entryDeleted: 'Entry deleted',
deleteFailed: 'Failed to delete entry',
resetSuccess: 'Catalog reset to defaults',
resetFailed: 'Failed to reset catalog',
exported: 'Exported {{count}} entries',
imported: 'Imported {{added}} entries ({{skipped}} skipped)',
importFailed: 'Failed to import: invalid JSON format',
exportTooltip: 'Export catalog to JSON',
importTooltip: 'Import catalog from JSON',
resetTooltip: 'Reset to defaults',
},
colorCatalog: {
title: 'Color Catalog',
description: 'Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.',
searchColors: 'Search colors...',
allManufacturers: 'All manufacturers',
addNewColor: 'Add New Color',
manufacturer: 'Manufacturer',
colorName: 'Color Name',
hex: 'Hex',
materialOptional: 'Material (optional)',
showing: 'Showing {{filtered}} of {{total}} colors',
noMatch: 'No colors match your search',
empty: 'No colors in catalog',
deleteColor: 'Delete Color',
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
resetCatalog: 'Reset Color Catalog',
resetConfirm: 'Reset catalog to defaults? This will remove all custom colors.',
sync: 'Sync',
starting: 'Starting...',
syncTooltip: 'Sync from FilamentColors.xyz (2000+ colors, may take a minute)',
loadFailed: 'Failed to load color catalog',
fieldsRequired: 'Manufacturer, color name, and hex color are required',
colorAdded: 'Color added',
addFailed: 'Failed to add color',
colorUpdated: 'Color updated',
updateFailed: 'Failed to update color',
colorDeleted: 'Color deleted',
deleteFailed: 'Failed to delete color',
resetSuccess: 'Color catalog reset to defaults',
resetFailed: 'Failed to reset catalog',
syncUpToDate: 'Already up to date ({{count}} colors checked)',
syncComplete: 'Added {{added}} new colors ({{skipped}} already existed)',
syncError: 'Sync error',
syncFailed: 'Failed to sync from FilamentColors.xyz',
exported: 'Exported {{count}} colors',
imported: 'Imported {{added}} colors ({{skipped}} skipped)',
importFailed: 'Failed to import: invalid JSON format',
},
},
// Notifications (for push notifications)
@@ -2327,6 +2427,150 @@ export default {
reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',
},
// Inventory
inventory: {
title: 'Spool Inventory',
addSpool: 'Add Spool',
editSpool: 'Edit Spool',
material: 'Material',
selectMaterial: 'Select material...',
subtype: 'Subtype',
brand: 'Brand',
searchBrand: 'Search brand...',
useCustomBrand: 'Use "{{brand}}"',
colorName: 'Color Name',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: 'Color',
hexColor: 'Hex Color',
pickColor: 'Pick custom color',
labelWeight: 'Label Weight',
coreWeight: 'Empty Spool Weight',
searchSpoolWeight: 'Search spool weight...',
weightUsed: 'Used',
slicerFilament: 'Slicer Filament ID',
slicerFilamentName: 'Slicer Preset Name',
slicerPreset: 'Slicer Preset',
searchPresets: 'Search filament presets...',
selectedPreset: 'Selected',
noPresetsFound: 'No presets found',
tempOverrides: 'Temperature Overrides',
note: 'Note',
notePlaceholder: 'Any additional notes about this spool...',
archive: 'Archive',
restore: 'Restore',
noSpools: 'No spools yet. Add your first spool to get started.',
kProfiles: 'K-Profiles',
addKProfile: 'Add K-Profile',
assignSpool: 'Assign Spool',
unassignSpool: 'Unassign',
assignSuccess: 'Spool assigned and AMS slot configured',
assignFailed: 'Failed to assign spool',
selectSpool: 'Select a spool to assign to this slot',
assigned: 'Assigned',
assigning: 'Assigning...',
searchSpools: 'Search spools...',
allMaterials: 'All Materials',
filterByBrand: 'Filter by brand...',
showArchived: 'Show archived',
spoolCreated: 'Spool created',
spoolUpdated: 'Spool updated',
spoolDeleted: 'Spool deleted',
spoolArchived: 'Spool archived',
spoolRestored: 'Spool restored',
deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
advancedSettings: 'Advanced Settings',
// Tabs
filamentInfoTab: 'Filament Info',
paProfileTab: 'PA Profile',
filamentInfo: 'Filament',
additional: 'Additional',
// Cloud
loadingPresets: 'Loading cloud presets...',
cloudConnected: 'Cloud connected',
cloudNotConnected: 'Cloud not connected (using defaults)',
// Colors
recentColors: 'Recent',
searchColors: 'Search colors...',
searchResults: 'Search results',
allColors: 'All colors',
commonColors: 'Common colors',
showLess: 'Show less',
showAll: 'Show all',
noColorsFound: 'No colors match your search',
noResults: 'No matches found',
// PA Profiles
selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
matchingFilter: 'Matching',
anyBrand: 'Any brand',
anyVariant: 'Any variant',
autoSelect: 'Auto-select',
matches: 'matches',
match: 'match',
noMatches: 'No matches',
connected: 'Connected',
offline: 'Offline',
printerOffline: 'Printer is offline. Connect to view calibration profiles.',
noKProfilesMatch: 'No K-profiles match the selected filament.',
leftNozzle: 'Left Nozzle',
rightNozzle: 'Right Nozzle',
profilesSelected: 'calibration profile(s) selected',
// Stats & enhanced table
totalInventory: 'Total Inventory',
totalConsumed: 'Total Consumed',
byMaterial: 'By Material',
inPrinter: 'In Printer',
lowStock: 'Low Stock',
sinceTracking: 'Since tracking started',
loadedInAms: 'Loaded in AMS/Ext',
remaining: 'Remaining',
lowStockThreshold: '<20% remaining',
search: 'Search spools...',
showing: 'Showing',
to: 'to',
of: 'of',
show: 'Show',
spools: 'spools',
spool: 'spool',
page: 'Page',
noSpoolsMatch: 'No results found',
noSpoolsMatchDesc: 'Try adjusting your search or filters to find what you\'re looking for.',
active: 'Active',
archived: 'Archived',
all: 'All',
used: 'Used',
new: 'New',
clearFilters: 'Clear filters',
table: 'Table',
cards: 'Cards',
net: 'Net',
// Column config
columns: 'Columns',
configureColumns: 'Configure Columns',
configureColumnsDesc: 'Drag to reorder columns or use arrows. Toggle visibility with the eye icon.',
visible: 'visible',
reset: 'Reset',
cancel: 'Cancel',
applyChanges: 'Apply Changes',
moveUp: 'Move up',
moveDown: 'Move down',
hideColumn: 'Hide column',
showColumn: 'Show column',
// Tag linking
linkToSpool: 'Link to Spool',
tagLinked: 'Tag linked to spool',
tagLinkFailed: 'Failed to link tag',
tagAlreadyLinked: 'Tag already linked to another spool',
unknownTag: 'Unknown RFID tag detected',
// Usage history
usageHistory: 'Usage History',
noUsageHistory: 'No usage recorded yet',
printName: 'Print Name',
weightConsumed: 'Weight Consumed',
clearHistory: 'Clear',
historyCleared: 'Usage history cleared',
},
// Timelapse
timelapse: {
title: 'Timelapse',
@@ -2945,6 +3189,7 @@ export default {
noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
noMatchingPresets: 'No matching presets found.',
custom: 'Custom',
builtin: 'Built-in',
settingsSentToPrinter: 'Settings sent to printer',
filamentProfile: 'Filament Profile',
},
+239 -3
View File
@@ -7,6 +7,7 @@ export default {
profiles: 'プロファイル',
maintenance: 'メンテナンス',
projects: 'プロジェクト',
inventory: 'インベントリ',
files: 'ファイル管理',
settings: '設定',
system: 'システム',
@@ -91,6 +92,8 @@ export default {
enable: '有効化',
linkNotFound: 'リンクが見つかりません',
reset: 'リセット',
export: 'エクスポート',
import: 'インポート',
selectAll: 'すべて選択',
deselectAll: 'すべて選択解除',
unchanged: '変更なし',
@@ -113,8 +116,6 @@ export default {
show: '表示',
hide: '非表示',
back: '戻る',
export: 'エクスポート',
import: 'インポート',
retry: 'リトライ',
model: 'モデル',
ok: 'OK',
@@ -1384,10 +1385,108 @@ export default {
leaveEmptyForAnonymous: '匿名の場合は空のまま',
leaveEmptyForNoAuth: '認証なしの場合は空のまま',
enterDescriptionOptional: '説明を入力(任意)',
spoolmanEnabled: 'Spoolman連携を有効化',
// フィラメント追跡モード
filamentTracking: 'フィラメント追跡',
filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
trackingModeBuiltIn: '内蔵インベントリ',
trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',
builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
// Spoolman設定
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
spoolmanConnected: '接続中',
spoolmanDisconnected: '未接続',
status: 'ステータス',
connect: '接続',
disconnect: '切断',
howSyncWorks: '同期の仕組み',
syncInfoRfidOnly: 'RFIDを搭載した公式Bambu Labスプールのみ同期されます',
syncInfoAutoCreate: '新しいスプールは初回同期時にSpoolmanに自動作成されます',
syncInfoThirdPartySkipped: 'Bambu Lab以外のスプール(サードパーティ、リフィル)はスキップされます',
linkingExistingSpools: '既存スプールのリンク',
linkingExistingSpoolsDesc: '既存のSpoolmanスプールをAMSにリンクするには、AMSスロットにカーソルを合わせて「Spoolmanにリンク」をクリックしてください。',
syncMode: '同期モード',
syncModeAuto: '自動',
syncModeManual: '手動のみ',
syncModeAutoDesc: '変更が検出されるとAMSデータが自動的に同期されます',
syncModeManualDesc: '手動でトリガーした場合のみ同期',
syncAmsData: 'AMSデータを同期',
syncAmsDataDesc: 'プリンターのAMSデータをSpoolmanに手動同期',
allPrinters: '全プリンター',
catalog: {
spoolCatalog: 'スプールカタログ',
spoolCatalogDescription: 'ブランド/タイプ別の空スプール重量。スプール追加時の自動重量検索に使用されます。',
searchCatalog: 'カタログを検索...',
addNewEntry: '新しいエントリを追加',
namePlaceholder: '名前(例:Bambu Lab - プラスチック)',
weight: '重量',
type: 'タイプ',
default: 'デフォルト',
custom: 'カスタム',
noMatch: '検索に一致するエントリがありません',
empty: 'カタログにエントリがありません',
deleteEntry: 'エントリを削除',
deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
resetCatalog: 'カタログをリセット',
resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムエントリはすべて削除されます。',
loadFailed: 'スプールカタログの読み込みに失敗しました',
nameWeightRequired: '名前と重量は必須です',
entryAdded: 'エントリを追加しました',
addFailed: 'エントリの追加に失敗しました',
entryUpdated: 'エントリを更新しました',
updateFailed: 'エントリの更新に失敗しました',
entryDeleted: 'エントリを削除しました',
deleteFailed: 'エントリの削除に失敗しました',
resetSuccess: 'カタログをデフォルトにリセットしました',
resetFailed: 'カタログのリセットに失敗しました',
exported: '{{count}}件のエントリをエクスポートしました',
imported: '{{added}}件のエントリをインポートしました({{skipped}}件スキップ)',
importFailed: 'インポートに失敗しました:無効なJSON形式',
exportTooltip: 'カタログをJSONにエクスポート',
importTooltip: 'JSONからカタログをインポート',
resetTooltip: 'デフォルトにリセット',
},
colorCatalog: {
title: 'カラーカタログ',
description: 'メーカー/素材別のフィラメントカラー。スプール追加時の自動カラー検索に使用されます。',
searchColors: 'カラーを検索...',
allManufacturers: 'すべてのメーカー',
addNewColor: '新しいカラーを追加',
manufacturer: 'メーカー',
colorName: 'カラー名',
hex: 'Hex',
materialOptional: '素材(任意)',
showing: '{{total}}件中{{filtered}}件を表示',
noMatch: '検索に一致するカラーがありません',
empty: 'カタログにカラーがありません',
deleteColor: 'カラーを削除',
deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
resetCatalog: 'カラーカタログをリセット',
resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムカラーはすべて削除されます。',
sync: '同期',
starting: '開始中...',
syncTooltip: 'FilamentColors.xyzから同期(2000+カラー)',
loadFailed: 'カラーカタログの読み込みに失敗しました',
fieldsRequired: 'メーカー、カラー名、Hexカラーは必須です',
colorAdded: 'カラーを追加しました',
addFailed: 'カラーの追加に失敗しました',
colorUpdated: 'カラーを更新しました',
updateFailed: 'カラーの更新に失敗しました',
colorDeleted: 'カラーを削除しました',
deleteFailed: 'カラーの削除に失敗しました',
resetSuccess: 'カラーカタログをデフォルトにリセットしました',
resetFailed: 'カタログのリセットに失敗しました',
syncUpToDate: '最新の状態です({{count}}件のカラーを確認)',
syncComplete: '{{added}}件の新しいカラーを追加しました({{skipped}}件は既に存在)',
syncError: '同期エラー',
syncFailed: 'FilamentColors.xyzからの同期に失敗しました',
exported: '{{count}}件のカラーをエクスポートしました',
imported: '{{added}}件のカラーをインポートしました({{skipped}}件スキップ)',
importFailed: 'インポートに失敗しました:無効なJSON形式',
},
},
notification: {
printStarted: {
@@ -2260,6 +2359,142 @@ export default {
linkFailed: 'スプールのリンクに失敗しました',
spoolId: 'スプールID',
},
inventory: {
title: 'スプール在庫管理',
addSpool: 'スプールを追加',
editSpool: 'スプールを編集',
material: '素材',
selectMaterial: '素材を選択...',
subtype: 'サブタイプ',
brand: 'ブランド',
searchBrand: 'ブランドを検索...',
useCustomBrand: '「{{brand}}」を使用',
colorName: '色名',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: '色',
hexColor: 'HEXカラー',
pickColor: 'カスタムカラーを選択',
labelWeight: '表示重量',
coreWeight: '空スプール重量',
searchSpoolWeight: 'スプール重量を検索...',
weightUsed: '使用量',
slicerFilament: 'スライサーフィラメントID',
slicerFilamentName: 'スライサープリセット名',
slicerPreset: 'スライサープリセット',
searchPresets: 'フィラメントプリセットを検索...',
selectedPreset: '選択済み',
noPresetsFound: 'プリセットが見つかりません',
tempOverrides: '温度オーバーライド',
note: 'メモ',
notePlaceholder: 'このスプールに関する追加メモ...',
archive: 'アーカイブ',
restore: '復元',
noSpools: 'スプールがありません。最初のスプールを追加してください。',
kProfiles: 'Kプロファイル',
addKProfile: 'Kプロファイルを追加',
assignSpool: 'スプールを割り当て',
unassignSpool: '割り当て解除',
assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',
assignFailed: 'スプールの割り当てに失敗しました',
selectSpool: 'このスロットに割り当てるスプールを選択',
assigned: '割り当て済み',
assigning: '割り当て中...',
searchSpools: 'スプールを検索...',
allMaterials: 'すべての素材',
filterByBrand: 'ブランドで絞り込み...',
showArchived: 'アーカイブ済みを表示',
spoolCreated: 'スプールを作成しました',
spoolUpdated: 'スプールを更新しました',
spoolDeleted: 'スプールを削除しました',
spoolArchived: 'スプールをアーカイブしました',
spoolRestored: 'スプールを復元しました',
deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
advancedSettings: '詳細設定',
filamentInfoTab: 'フィラメント情報',
paProfileTab: 'PAプロファイル',
filamentInfo: 'フィラメント',
additional: '追加情報',
loadingPresets: 'クラウドプリセットを読み込み中...',
cloudConnected: 'クラウド接続済み',
cloudNotConnected: 'クラウド未接続(デフォルト使用)',
recentColors: '最近',
searchColors: '色を検索...',
searchResults: '検索結果',
allColors: 'すべての色',
commonColors: '一般的な色',
showLess: '少なく表示',
showAll: 'すべて表示',
noColorsFound: '一致する色がありません',
noResults: '結果なし',
selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
matchingFilter: 'フィルター',
anyBrand: 'すべてのブランド',
anyVariant: 'すべてのバリアント',
autoSelect: '自動選択',
matches: '件一致',
match: '件一致',
noMatches: '一致なし',
connected: '接続済み',
offline: 'オフライン',
printerOffline: 'プリンターがオフラインです。接続してキャリブレーションプロファイルを表示してください。',
noKProfilesMatch: '選択したフィラメントに一致するKプロファイルがありません。',
leftNozzle: '左ノズル',
rightNozzle: '右ノズル',
profilesSelected: 'キャリブレーションプロファイル選択済み',
totalInventory: '在庫合計',
totalConsumed: '総消費量',
byMaterial: '素材別',
inPrinter: 'プリンター内',
lowStock: '残量少',
sinceTracking: '追跡開始以降',
loadedInAms: 'AMS/Extに装填中',
remaining: '残り',
lowStockThreshold: '残り20%未満',
search: 'スプールを検索...',
showing: '表示',
to: '〜',
of: '/',
show: '表示',
spools: 'スプール',
spool: 'スプール',
page: 'ページ',
noSpoolsMatch: '結果なし',
noSpoolsMatchDesc: '検索やフィルターを調整してみてください。',
active: 'アクティブ',
archived: 'アーカイブ済み',
all: 'すべて',
used: '使用済み',
new: '新規',
clearFilters: 'フィルターをクリア',
table: 'テーブル',
cards: 'カード',
net: '正味',
columns: '列',
configureColumns: '列の設定',
configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',
visible: '表示中',
reset: 'リセット',
cancel: 'キャンセル',
applyChanges: '変更を適用',
moveUp: '上へ移動',
moveDown: '下へ移動',
hideColumn: '列を非表示',
showColumn: '列を表示',
// タグリンク
linkToSpool: 'スプールにリンク',
tagLinked: 'タグがスプールにリンクされました',
tagLinkFailed: 'タグのリンクに失敗しました',
tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',
unknownTag: '不明なRFIDタグが検出されました',
// 使用履歴
usageHistory: '使用履歴',
noUsageHistory: 'まだ使用記録がありません',
printName: 'プリント名',
weightConsumed: '消費重量',
clearHistory: 'クリア',
historyCleared: '使用履歴がクリアされました',
},
timelapse: {
download: 'ダウンロード',
preview: 'プレビュー',
@@ -2666,6 +2901,7 @@ export default {
noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
noMatchingPresets: '一致するプリセットが見つかりません。',
custom: 'カスタム',
builtin: '内蔵',
settingsSentToPrinter: '設定をプリンターに送信しました',
filamentProfile: 'フィラメントプロファイル',
},
File diff suppressed because it is too large Load Diff
+133 -11
View File
@@ -47,7 +47,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { api, discoveryApi, firmwareApi } from '../api/client';
import { formatDateOnly } from '../utils/date';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -59,6 +59,7 @@ import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
import { LinkSpoolModal } from '../components/LinkSpoolModal';
import { AssignSpoolModal } from '../components/AssignSpoolModal';
import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
import { useToast } from '../contexts/ToastContext';
import { ChamberLight } from '../components/icons/ChamberLight';
@@ -1339,6 +1340,8 @@ function PrinterCard({
hasUnlinkedSpools = false,
linkedSpools,
spoolmanUrl,
onGetAssignment,
onUnassignSpool,
timeFormat = 'system',
cameraViewMode = 'window',
onOpenEmbeddedCamera,
@@ -1359,6 +1362,9 @@ function PrinterCard({
hasUnlinkedSpools?: boolean;
linkedSpools?: Record<string, LinkedSpoolInfo>;
spoolmanUrl?: string | null;
spoolAssignments?: SpoolAssignment[];
onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined;
onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void;
timeFormat?: 'system' | '12h' | '24h';
cameraViewMode?: 'window' | 'embedded';
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
@@ -1388,7 +1394,16 @@ function PrinterCard({
mode: 'humidity' | 'temperature';
} | null>(null);
const [linkSpoolModal, setLinkSpoolModal] = useState<{
tagUid: string;
trayUuid: string;
printerId: number;
amsId: number;
trayId: number;
} | null>(null);
const [assignSpoolModal, setAssignSpoolModal] = useState<{
printerId: number;
amsId: number;
trayId: number;
trayInfo: { type: string; color: string; location: string };
} | null>(null);
const [configureSlotModal, setConfigureSlotModal] = useState<{
@@ -2691,6 +2706,7 @@ function PrinterCard({
kFactor: formatKValue(tray.k),
fillLevel: effectiveFill,
trayUuid: tray.tray_uuid || null,
tagUid: tray.tag_uid || null,
fillSource,
} : null;
@@ -2791,15 +2807,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: filamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: ams.id,
trayId: slotIdx,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: () => setAssignSpoolModal({
printerId: printer.id,
amsId: ams.id,
trayId: slotIdx,
trayInfo: {
type: filamentData.profile,
color: filamentData.colorHex || '',
location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
},
});
} : undefined,
}}
}),
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -2879,6 +2916,7 @@ function PrinterCard({
kFactor: formatKValue(tray.k),
fillLevel: htEffectiveFill,
trayUuid: tray.tray_uuid || null,
tagUid: tray.tag_uid || null,
fillSource: htFillSource,
} : null;
@@ -2992,15 +3030,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: filamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: ams.id,
trayId: htSlotId,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: () => setAssignSpoolModal({
printerId: printer.id,
amsId: ams.id,
trayId: htSlotId,
trayInfo: {
type: filamentData.profile,
color: filamentData.colorHex || '',
location: getAmsLabel(ams.id, ams.tray.length),
},
});
} : undefined,
}}
}),
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -3090,6 +3149,7 @@ function PrinterCard({
kFactor: formatKValue(extTray.k),
fillLevel: extSpoolmanFill, // Use Spoolman data if available
trayUuid: extTray.tray_uuid || null,
tagUid: extTray.tag_uid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
};
@@ -3138,15 +3198,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: extFilamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: 255,
trayId: 0,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, 255, 0);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: () => setAssignSpoolModal({
printerId: printer.id,
amsId: 255,
trayId: 0,
trayInfo: {
type: extFilamentData.profile,
color: extFilamentData.colorHex || '',
location: 'External Spool',
},
});
} : undefined,
}}
}),
onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -3776,8 +3857,23 @@ function PrinterCard({
<LinkSpoolModal
isOpen={!!linkSpoolModal}
onClose={() => setLinkSpoolModal(null)}
tagUid={linkSpoolModal.tagUid}
trayUuid={linkSpoolModal.trayUuid}
trayInfo={linkSpoolModal.trayInfo}
printerId={linkSpoolModal.printerId}
amsId={linkSpoolModal.amsId}
trayId={linkSpoolModal.trayId}
/>
)}
{/* Assign Spool Modal */}
{assignSpoolModal && (
<AssignSpoolModal
isOpen={!!assignSpoolModal}
onClose={() => setAssignSpoolModal(null)}
printerId={assignSpoolModal.printerId}
amsId={assignSpoolModal.amsId}
trayId={assignSpoolModal.trayId}
trayInfo={assignSpoolModal.trayInfo}
/>
)}
@@ -4804,6 +4900,28 @@ export function PrintersPage() {
});
const linkedSpools = linkedSpoolsData?.linked;
// Fetch spool assignments for inventory feature
const { data: spoolAssignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
staleTime: 30 * 1000,
});
const unassignMutation = useMutation({
mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
api.unassignSpool(printerId, amsId, trayId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
},
});
// Helper to find assignment for a specific slot
const getAssignment = (printerId: number, amsId: number, trayId: number): SpoolAssignment | undefined => {
return spoolAssignments?.find(
(a) => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId
);
};
// Create a map of printer_id -> maintenance info for quick lookup
const maintenanceByPrinter = maintenanceOverview?.reduce(
(acc, overview) => {
@@ -5112,6 +5230,8 @@ export function PrintersPage() {
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
@@ -5137,6 +5257,8 @@ export function PrintersPage() {
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
amsThresholds={settings ? {
humidityGood: Number(settings.ams_humidity_good) || 40,
humidityFair: Number(settings.ams_humidity_fair) || 60,
+12 -5
View File
@@ -18,6 +18,8 @@ import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
@@ -3145,9 +3147,12 @@ export function SettingsPage() {
{/* Filament Tab */}
{activeTab === 'filament' && localSettings && (
<>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
{/* Left Column - AMS Display Thresholds */}
<div className="flex-1 lg:max-w-xl">
{/* Left Column (1/3) - Mode Selector + AMS Thresholds */}
<div className="lg:w-1/3 space-y-6">
<SpoolmanSettings />
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.amsDisplayThresholds')}</h2>
@@ -3306,11 +3311,13 @@ export function SettingsPage() {
</Card>
</div>
{/* Right Column - Spoolman Integration */}
<div className="flex-1 lg:max-w-xl">
<SpoolmanSettings />
{/* Right Column (2/3) - Spool Catalog + Color Catalog */}
<div className="lg:w-2/3 space-y-6">
<SpoolCatalogSettings />
<ColorCatalogSettings />
</div>
</div>
</>
)}
{/* Delete API Key Confirmation */}