mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Sync
This commit is contained in:
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} · 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'} · #{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user