|
@@ -3,25 +3,32 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
|
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
|
|
import { api, ApiError } from '../api/client';
|
|
import { api, ApiError } from '../api/client';
|
|
|
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
|
|
|
|
|
|
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
|
|
import { Button } from './Button';
|
|
import { Button } from './Button';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
-import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
|
|
|
|
|
|
|
+import type {
|
|
|
|
|
+ CalibrationProfile,
|
|
|
|
|
+ ColorPreset,
|
|
|
|
|
+ PresetChoice,
|
|
|
|
|
+ PrinterWithCalibrations,
|
|
|
|
|
+ SpoolFormData,
|
|
|
|
|
+ SpoolFormMode,
|
|
|
|
|
+} from './spool-form/types';
|
|
|
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
|
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
|
|
-import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
|
|
|
|
|
|
|
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
|
|
|
import { MATERIALS } from './spool-form/constants';
|
|
import { MATERIALS } from './spool-form/constants';
|
|
|
import { FilamentSection } from './spool-form/FilamentSection';
|
|
import { FilamentSection } from './spool-form/FilamentSection';
|
|
|
import { ColorSection } from './spool-form/ColorSection';
|
|
import { ColorSection } from './spool-form/ColorSection';
|
|
|
import { AdditionalSection } from './spool-form/AdditionalSection';
|
|
import { AdditionalSection } from './spool-form/AdditionalSection';
|
|
|
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
|
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
|
|
-import { PAProfileSection } from './spool-form/PAProfileSection';
|
|
|
|
|
|
|
+import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
|
|
|
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
|
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
|
|
import {
|
|
import {
|
|
|
invalidateInventoryLocations,
|
|
invalidateInventoryLocations,
|
|
|
invalidateSpoolAndLocationQueries,
|
|
invalidateSpoolAndLocationQueries,
|
|
|
} from '../utils/inventoryQueries';
|
|
} from '../utils/inventoryQueries';
|
|
|
|
|
|
|
|
-type TabId = 'filament' | 'pa-profile';
|
|
|
|
|
|
|
+type TabId = 'filament' | 'appearance' | 'printers';
|
|
|
|
|
|
|
|
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
|
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
|
|
|
|
|
|
@@ -75,6 +82,7 @@ export function SpoolFormModal({
|
|
|
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
|
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
|
|
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
|
|
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
|
|
|
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
|
|
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
|
|
|
|
|
+ const [orcaSettingIds, setOrcaSettingIds] = useState<Set<string>>(new Set());
|
|
|
const [presetInputValue, setPresetInputValue] = useState('');
|
|
const [presetInputValue, setPresetInputValue] = useState('');
|
|
|
|
|
|
|
|
// Spool catalog
|
|
// Spool catalog
|
|
@@ -104,16 +112,31 @@ export function SpoolFormModal({
|
|
|
|
|
|
|
|
// PA Profile state
|
|
// PA Profile state
|
|
|
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
|
|
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
|
|
|
- const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
|
|
|
|
|
- const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
|
|
|
|
|
|
|
+ // Whether the calibration fetch above is still running. Without it the
|
|
|
|
|
+ // Printers tab renders its "no printers configured" empty state while the
|
|
|
|
|
+ // printers are still being asked -- which reads as a wrong answer rather
|
|
|
|
|
+ // than as a wait, and the fetch is several round trips per machine.
|
|
|
|
|
+ const [loadingCalibrations, setLoadingCalibrations] = useState(false);
|
|
|
|
|
+ // One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather
|
|
|
|
|
+ // than a Set of composite keys because the Printers tab presents each hotend
|
|
|
|
|
+ // as a single-choice dropdown -- the shape makes "two profiles for one
|
|
|
|
|
+ // hotend" unrepresentable instead of relying on eviction logic to prevent it.
|
|
|
|
|
+ const [selectedProfiles, setSelectedProfiles] = useState<Map<string, CalibrationProfile>>(new Map());
|
|
|
|
|
+ // Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`.
|
|
|
|
|
+ // Only the models the user has actually overridden are present; an absent
|
|
|
|
|
+ // entry means "inherit this spool's own preset", which is exactly what the
|
|
|
|
|
+ // backend cascade does with a missing row.
|
|
|
|
|
+ const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
|
|
|
|
|
+ const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
|
|
|
|
|
|
|
// Use prop if provided, otherwise use self-fetched data
|
|
// Use prop if provided, otherwise use self-fetched data
|
|
|
const resolvedCalibrations = printersWithCalibrations.length > 0
|
|
const resolvedCalibrations = printersWithCalibrations.length > 0
|
|
|
? printersWithCalibrations
|
|
? printersWithCalibrations
|
|
|
: fetchedCalibrations;
|
|
: fetchedCalibrations;
|
|
|
|
|
|
|
|
- // Count selected PA profiles for tab badge
|
|
|
|
|
- const selectedProfileCount = selectedProfiles.size;
|
|
|
|
|
|
|
+ // Tab badge: everything the user has configured under Printers, K profiles
|
|
|
|
|
+ // and preset overrides alike, since both live on that tab now.
|
|
|
|
|
+ const selectedProfileCount = selectedProfiles.size + modelPresets.size;
|
|
|
|
|
|
|
|
// Fetch Spoolman filament catalog when in Spoolman mode
|
|
// Fetch Spoolman filament catalog when in Spoolman mode
|
|
|
// retry:false — Spoolman may be intentionally disabled (400); don't flood the server
|
|
// retry:false — Spoolman may be intentionally disabled (400); don't flood the server
|
|
@@ -169,6 +192,10 @@ export function SpoolFormModal({
|
|
|
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
|
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
|
|
setCloudAuthenticated(bambuConnected || orcaConnected);
|
|
setCloudAuthenticated(bambuConnected || orcaConnected);
|
|
|
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
|
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
|
|
|
|
+ // The two clouds are merged into one list, so remember which ids came
|
|
|
|
|
+ // from Orca -- it is the only way the origin badge can tell them
|
|
|
|
|
+ // apart afterwards.
|
|
|
|
|
+ setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id)));
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
if (cancelled) return;
|
|
if (cancelled) return;
|
|
|
console.error('Failed to fetch cloud presets:', e);
|
|
console.error('Failed to fetch cloud presets:', e);
|
|
@@ -189,27 +216,40 @@ export function SpoolFormModal({
|
|
|
// Fetch printer calibrations if not provided via props
|
|
// Fetch printer calibrations if not provided via props
|
|
|
if (printersWithCalibrations.length === 0) {
|
|
if (printersWithCalibrations.length === 0) {
|
|
|
(async () => {
|
|
(async () => {
|
|
|
|
|
+ setLoadingCalibrations(true);
|
|
|
try {
|
|
try {
|
|
|
const printers = await api.getPrinters();
|
|
const printers = await api.getPrinters();
|
|
|
const statuses = await Promise.all(
|
|
const statuses = await Promise.all(
|
|
|
printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
|
|
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) {
|
|
|
|
|
- // Fetch across every installed nozzle so dual-nozzle printers
|
|
|
|
|
- // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
|
|
|
|
|
- calibrations = await fetchPrinterCalibrations(printer.id, status);
|
|
|
|
|
- }
|
|
|
|
|
- results.push({ printer: { ...printer, connected }, calibrations });
|
|
|
|
|
- }
|
|
|
|
|
- setFetchedCalibrations(results);
|
|
|
|
|
|
|
+ // Printers in parallel, diameters within a printer in series.
|
|
|
|
|
+ // Separate machines are separate MQTT connections and do not
|
|
|
|
|
+ // interfere; it is one printer's own firmware that drops a
|
|
|
|
|
+ // concurrent burst of calibration requests (see
|
|
|
|
|
+ // fetchPrinterCalibrations). Walking the fleet one machine at a
|
|
|
|
|
+ // time made the whole tab wait for the sum of every printer.
|
|
|
|
|
+ const results = await Promise.all(
|
|
|
|
|
+ printers.map(async (printer, i) => {
|
|
|
|
|
+ const status = statuses[i];
|
|
|
|
|
+ const connected = status?.connected ?? false;
|
|
|
|
|
+ let calibrations: PrinterWithCalibrations['calibrations'] = [];
|
|
|
|
|
+ if (connected) {
|
|
|
|
|
+ // Across every nozzle size, so a profile for a size that is
|
|
|
|
|
+ // not currently fitted is still offered (#2618 fetched only
|
|
|
|
|
+ // the fitted ones).
|
|
|
|
|
+ calibrations = await fetchPrinterCalibrations(printer.id, status);
|
|
|
|
|
+ }
|
|
|
|
|
+ // Keep the reported nozzle hardware: the Printers tab lists a
|
|
|
|
|
+ // model's installed diameters from it. Read as a set of
|
|
|
|
|
+ // diameters only -- never indexed by extruder.
|
|
|
|
|
+ return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles };
|
|
|
|
|
+ }),
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!cancelled) setFetchedCalibrations(results);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
console.error('Failed to fetch printer calibrations:', e);
|
|
console.error('Failed to fetch printer calibrations:', e);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!cancelled) setLoadingCalibrations(false);
|
|
|
}
|
|
}
|
|
|
})();
|
|
})();
|
|
|
}
|
|
}
|
|
@@ -228,8 +268,8 @@ export function SpoolFormModal({
|
|
|
|
|
|
|
|
// Build filament options: cloud → local → fallback
|
|
// Build filament options: cloud → local → fallback
|
|
|
const filamentOptions = useMemo(
|
|
const filamentOptions = useMemo(
|
|
|
- () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
|
|
|
|
|
- [cloudPresets, localPresets, builtinFilaments],
|
|
|
|
|
|
|
+ () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds),
|
|
|
|
|
+ [cloudPresets, localPresets, builtinFilaments, orcaSettingIds],
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
// Extract brands from presets
|
|
// Extract brands from presets
|
|
@@ -374,22 +414,34 @@ export function SpoolFormModal({
|
|
|
});
|
|
});
|
|
|
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
|
|
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
|
|
|
|
|
|
|
|
- // Load K-profiles for this spool
|
|
|
|
|
|
|
+ // Load K-profiles for this spool. The stored row carries everything
|
|
|
|
|
+ // the picker needs to show the selection before the printer answers,
|
|
|
|
|
+ // so an offline printer still renders what was chosen for it.
|
|
|
if (spool.k_profiles && spool.k_profiles.length > 0) {
|
|
if (spool.k_profiles && spool.k_profiles.length > 0) {
|
|
|
- const profileKeys = new Set<string>();
|
|
|
|
|
|
|
+ const chosen = new Map<string, CalibrationProfile>();
|
|
|
for (const p of spool.k_profiles) {
|
|
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'}`);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (p.cali_idx === null || p.cali_idx === undefined) continue;
|
|
|
|
|
+ const diameter = (p.nozzle_diameter || '').trim() || '0.4';
|
|
|
|
|
+ const extruder = p.extruder ?? 0;
|
|
|
|
|
+ chosen.set(hotendKey(p.printer_id, extruder, diameter), {
|
|
|
|
|
+ cali_idx: p.cali_idx,
|
|
|
|
|
+ filament_id: '',
|
|
|
|
|
+ setting_id: p.setting_id || '',
|
|
|
|
|
+ name: p.name || '',
|
|
|
|
|
+ k_value: p.k_value,
|
|
|
|
|
+ n_coef: 0,
|
|
|
|
|
+ extruder_id: extruder,
|
|
|
|
|
+ nozzle_diameter: diameter,
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
- setSelectedProfiles(profileKeys);
|
|
|
|
|
|
|
+ setSelectedProfiles(chosen);
|
|
|
} else {
|
|
} else {
|
|
|
- setSelectedProfiles(new Set());
|
|
|
|
|
|
|
+ setSelectedProfiles(new Map());
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
setFormData(defaultFormData);
|
|
setFormData(defaultFormData);
|
|
|
setPresetInputValue('');
|
|
setPresetInputValue('');
|
|
|
- setSelectedProfiles(new Set());
|
|
|
|
|
|
|
+ setSelectedProfiles(new Map());
|
|
|
}
|
|
}
|
|
|
// Reset on every open, not just the create path (#1905). The modal keeps
|
|
// Reset on every open, not just the create path (#1905). The modal keeps
|
|
|
// its state while closed, and the Quick Add toggle only renders in create
|
|
// its state while closed, and the Quick Add toggle only renders in create
|
|
@@ -400,11 +452,51 @@ export function SpoolFormModal({
|
|
|
setQuantity(1);
|
|
setQuantity(1);
|
|
|
setErrors({});
|
|
setErrors({});
|
|
|
setActiveTab('filament');
|
|
setActiveTab('filament');
|
|
|
|
|
+ setSelectedGroupId('');
|
|
|
|
|
+ // Cleared on every open, both branches: the modal keeps its state while
|
|
|
|
|
+ // closed, so editing spool B after spool A would otherwise show (and
|
|
|
|
|
+ // save) A's per-model overrides on B. Refilled by the fetch below.
|
|
|
|
|
+ setModelPresets(new Map());
|
|
|
setWeightTouched(false);
|
|
setWeightTouched(false);
|
|
|
setLocationIdTouched(false);
|
|
setLocationIdTouched(false);
|
|
|
}
|
|
}
|
|
|
}, [isOpen, spool, mode, isCopying]);
|
|
}, [isOpen, spool, mode, isCopying]);
|
|
|
|
|
|
|
|
|
|
+ // Load this spool's per-printer-model preset overrides. Fetched rather than
|
|
|
|
|
+ // read off the spool: they are deliberately not embedded in the spool
|
|
|
|
|
+ // response, which the inventory list returns once per spool the user owns.
|
|
|
|
|
+ // Copying a spool copies its overrides -- they describe the filament on the
|
|
|
|
|
+ // spool, which is what a copy has too.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isOpen || !spool) return;
|
|
|
|
|
+ let cancelled = false;
|
|
|
|
|
+ const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets;
|
|
|
|
|
+ load(spool.id)
|
|
|
|
|
+ .then(rows => {
|
|
|
|
|
+ if (cancelled) return;
|
|
|
|
|
+ const next = new Map<string, PresetChoice>();
|
|
|
|
|
+ for (const row of rows) {
|
|
|
|
|
+ next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
|
|
|
|
|
+ code: row.slicer_filament || '',
|
|
|
|
|
+ name: row.slicer_filament_name || '',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ setModelPresets(next);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(e => {
|
|
|
|
|
+ // Non-fatal: the tab still works, it just starts with nothing
|
|
|
|
|
+ // overridden. Saving from that state WOULD clear the stored rows, so
|
|
|
|
|
+ // say so rather than letting the user save over what they cannot see.
|
|
|
|
|
+ if (cancelled) return;
|
|
|
|
|
+ console.error('Failed to load filament preset overrides:', e);
|
|
|
|
|
+ showToast(t('inventory.filamentPresetsLoadFailed'), 'warning');
|
|
|
|
|
+ });
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ cancelled = true;
|
|
|
|
|
+ };
|
|
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
+ }, [isOpen, spool?.id, spoolmanMode]);
|
|
|
|
|
+
|
|
|
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
|
|
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
|
|
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
|
|
@@ -416,12 +508,6 @@ export function SpoolFormModal({
|
|
|
}
|
|
}
|
|
|
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
|
|
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
|
|
|
|
|
|
|
|
- // 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
|
|
// Update field helper
|
|
|
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
|
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
|
@@ -481,7 +567,7 @@ export function SpoolFormModal({
|
|
|
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
|
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
|
|
onSuccess: async (newSpool) => {
|
|
onSuccess: async (newSpool) => {
|
|
|
if (newSpool?.id) {
|
|
if (newSpool?.id) {
|
|
|
- const ok = await saveKProfiles(newSpool.id);
|
|
|
|
|
|
|
+ const ok = await savePrinterProfiles(newSpool.id);
|
|
|
if (!ok) return;
|
|
if (!ok) return;
|
|
|
}
|
|
}
|
|
|
await refreshSpoolQueries();
|
|
await refreshSpoolQueries();
|
|
@@ -518,9 +604,12 @@ export function SpoolFormModal({
|
|
|
? spoolmanResult.created
|
|
? spoolmanResult.created
|
|
|
: (result as InventorySpool[]);
|
|
: (result as InventorySpool[]);
|
|
|
|
|
|
|
|
- if (selectedProfiles.size > 0) {
|
|
|
|
|
|
|
+ // Bulk create: every copy gets the same profiles and overrides. Skipped
|
|
|
|
|
+ // entirely when the user configured neither, so a plain bulk add does
|
|
|
|
|
+ // not fire two writes per spool.
|
|
|
|
|
+ if (selectedProfiles.size > 0 || modelPresets.size > 0) {
|
|
|
for (const s of createdSpools) {
|
|
for (const s of createdSpools) {
|
|
|
- await saveKProfiles(s.id);
|
|
|
|
|
|
|
+ await savePrinterProfiles(s.id);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
await refreshSpoolQueries();
|
|
await refreshSpoolQueries();
|
|
@@ -554,7 +643,7 @@ export function SpoolFormModal({
|
|
|
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
|
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
|
|
onSuccess: async () => {
|
|
onSuccess: async () => {
|
|
|
if (spool?.id) {
|
|
if (spool?.id) {
|
|
|
- const ok = await saveKProfiles(spool.id);
|
|
|
|
|
|
|
+ const ok = await savePrinterProfiles(spool.id);
|
|
|
if (!ok) return;
|
|
if (!ok) return;
|
|
|
}
|
|
}
|
|
|
await refreshSpoolQueries();
|
|
await refreshSpoolQueries();
|
|
@@ -625,6 +714,18 @@ export function SpoolFormModal({
|
|
|
queryFn: api.getSettings,
|
|
queryFn: api.getSettings,
|
|
|
enabled: isOpen,
|
|
enabled: isOpen,
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+ // Backend Bambu printer-model registry, so the Printers tab can read the
|
|
|
|
|
+ // model out of a preset name and offer each model only its own presets. The
|
|
|
|
|
+ // same query key and staleTime the Configure AMS Slot modal uses -- the
|
|
|
|
|
+ // registry only changes across backend releases, so this is a cache hit
|
|
|
|
|
+ // whenever that modal has been opened.
|
|
|
|
|
+ const { data: printerModelsData } = useQuery({
|
|
|
|
|
+ queryKey: ['slicerPrinterModels'],
|
|
|
|
|
+ queryFn: api.getSlicerPrinterModels,
|
|
|
|
|
+ enabled: isOpen,
|
|
|
|
|
+ staleTime: Infinity,
|
|
|
|
|
+ });
|
|
|
const availableCategories = (() => {
|
|
const availableCategories = (() => {
|
|
|
const set = new Set<string>();
|
|
const set = new Set<string>();
|
|
|
for (const s of allSpools ?? []) {
|
|
for (const s of allSpools ?? []) {
|
|
@@ -660,65 +761,61 @@ export function SpoolFormModal({
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Save K-profiles for selected calibrations. Returns false if any error occurred.
|
|
|
|
|
- const saveKProfiles = async (spoolId: number): Promise<boolean> => {
|
|
|
|
|
- const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
|
|
|
|
|
-
|
|
|
|
|
- if (selectedProfiles.size === 0) {
|
|
|
|
|
- try {
|
|
|
|
|
- await saveApi(spoolId, []);
|
|
|
|
|
- return true;
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- console.error('Failed to save K-profiles:', e);
|
|
|
|
|
- showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Save everything the Printers tab holds: one K profile per hotend and the
|
|
|
|
|
+ // per-printer-model preset overrides. Returns false if either write failed,
|
|
|
|
|
+ // which keeps the modal open so the user does not lose what they picked.
|
|
|
|
|
+ const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
|
|
|
|
|
+ const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
|
|
|
|
|
+ const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
|
|
|
|
|
+
|
|
|
|
|
+ // The selection Map is keyed by hotend and holds the calibration itself,
|
|
|
|
|
+ // so nothing has to be resolved back out of the printer's live list. That
|
|
|
|
|
+ // also fixes a real defect in the old key-based lookup: it matched a
|
|
|
|
|
+ // calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE --
|
|
|
|
|
+ // on a dual-nozzle printer it could resolve the other hotend's entry and
|
|
|
|
|
+ // persist that entry's K value and diameter.
|
|
|
|
|
+ const profiles: SpoolKProfileInput[] = [];
|
|
|
|
|
+ for (const [key, cal] of selectedProfiles) {
|
|
|
|
|
+ const [printerIdStr, extruderStr, diameter] = key.split(':');
|
|
|
|
|
+ profiles.push({
|
|
|
|
|
+ printer_id: parseInt(printerIdStr),
|
|
|
|
|
+ extruder: parseInt(extruderStr),
|
|
|
|
|
+ nozzle_diameter: diameter || '0.4',
|
|
|
|
|
+ k_value: cal.k_value,
|
|
|
|
|
+ name: cal.name || null,
|
|
|
|
|
+ cali_idx: cal.cali_idx,
|
|
|
|
|
+ setting_id: cal.setting_id || null,
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const profiles: SpoolKProfileInput[] = [];
|
|
|
|
|
- let dropped = 0;
|
|
|
|
|
- for (const key of selectedProfiles) {
|
|
|
|
|
- const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
|
|
|
|
|
- const printerId = parseInt(printerIdStr);
|
|
|
|
|
- const caliIdx = parseInt(caliIdxStr);
|
|
|
|
|
- const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
|
|
|
|
|
-
|
|
|
|
|
- const pc = 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,
|
|
|
|
|
- });
|
|
|
|
|
- } else {
|
|
|
|
|
- dropped++;
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- dropped++;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const presets: SpoolFilamentPresetInput[] = [];
|
|
|
|
|
+ for (const [key, choice] of modelPresets) {
|
|
|
|
|
+ const { model, diameter } = parsePresetKey(key);
|
|
|
|
|
+ if (!model) continue;
|
|
|
|
|
+ presets.push({
|
|
|
|
|
+ printer_model: model,
|
|
|
|
|
+ nozzle_diameter: diameter,
|
|
|
|
|
+ slicer_filament: choice.code || null,
|
|
|
|
|
+ slicer_filament_name: choice.name || null,
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (dropped > 0) {
|
|
|
|
|
- console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
|
|
|
|
|
|
|
+ // Both are full replacements, so both run even when empty -- that is how
|
|
|
|
|
+ // the user clears the last profile or the last override.
|
|
|
|
|
+ try {
|
|
|
|
|
+ await saveKApi(spoolId, profiles);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('Failed to save K-profiles:', e);
|
|
|
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
|
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (profiles.length > 0) {
|
|
|
|
|
- try {
|
|
|
|
|
- await saveApi(spoolId, profiles);
|
|
|
|
|
- return true;
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- console.error('Failed to save K-profiles:', e);
|
|
|
|
|
- showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ await savePresetApi(spoolId, presets);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('Failed to save filament preset overrides:', e);
|
|
|
|
|
+ showToast(t('inventory.filamentPresetSaveFailed'), 'warning');
|
|
|
|
|
+ return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
return true;
|
|
@@ -800,7 +897,12 @@ export function SpoolFormModal({
|
|
|
onClick={onClose}
|
|
onClick={onClose}
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
- <div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
|
|
|
|
|
|
|
+ {/* Wider than the old max-w-xl: the Printers tab is a model list beside
|
|
|
|
|
+ a detail pane holding a preset row per nozzle size and a hotend-by-
|
|
|
|
|
+ size grid, which needs room for both. Held constant across tabs
|
|
|
|
|
+ rather than sized per tab -- a modal that resizes as you switch tabs
|
|
|
|
|
+ reads as a layout bug. */}
|
|
|
|
|
+ <div className="relative w-full max-w-5xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
|
|
|
{/* Header */}
|
|
{/* Header */}
|
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
|
|
<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 flex items-baseline gap-2">
|
|
<h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
|
|
@@ -857,20 +959,33 @@ export function SpoolFormModal({
|
|
|
: 'text-bambu-gray hover:text-white'
|
|
: 'text-bambu-gray hover:text-white'
|
|
|
}`}
|
|
}`}
|
|
|
>
|
|
>
|
|
|
- <Palette className="w-4 h-4" />
|
|
|
|
|
|
|
+ <Beaker className="w-4 h-4" />
|
|
|
{t('inventory.filamentInfoTab')}
|
|
{t('inventory.filamentInfoTab')}
|
|
|
</button>
|
|
</button>
|
|
|
{!quickAdd && (
|
|
{!quickAdd && (
|
|
|
<button
|
|
<button
|
|
|
- onClick={() => setActiveTab('pa-profile')}
|
|
|
|
|
|
|
+ onClick={() => setActiveTab('appearance')}
|
|
|
|
|
+ className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
|
|
|
|
+ activeTab === 'appearance'
|
|
|
|
|
+ ? 'text-bambu-green border-b-2 border-bambu-green'
|
|
|
|
|
+ : 'text-bambu-gray hover:text-white'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Palette className="w-4 h-4" />
|
|
|
|
|
+ {t('inventory.colorAndCostTab')}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!quickAdd && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setActiveTab('printers')}
|
|
|
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
|
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
|
|
- activeTab === 'pa-profile'
|
|
|
|
|
|
|
+ activeTab === 'printers'
|
|
|
? 'text-bambu-green border-b-2 border-bambu-green'
|
|
? 'text-bambu-green border-b-2 border-bambu-green'
|
|
|
: 'text-bambu-gray hover:text-white'
|
|
: 'text-bambu-gray hover:text-white'
|
|
|
}`}
|
|
}`}
|
|
|
>
|
|
>
|
|
|
- <Beaker className="w-4 h-4" />
|
|
|
|
|
- {t('inventory.paProfileTab')}
|
|
|
|
|
|
|
+ <Zap className="w-4 h-4" />
|
|
|
|
|
+ {t('inventory.printersTab')}
|
|
|
{selectedProfileCount > 0 && (
|
|
{selectedProfileCount > 0 && (
|
|
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
|
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
|
|
|
{selectedProfileCount}
|
|
{selectedProfileCount}
|
|
@@ -926,6 +1041,9 @@ export function SpoolFormModal({
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : activeTab === 'appearance' ? (
|
|
|
|
|
+ <div className="space-y-6">
|
|
|
{/* Color Section */}
|
|
{/* Color Section */}
|
|
|
<div>
|
|
<div>
|
|
|
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
|
|
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
|
|
@@ -981,14 +1099,18 @@ export function SpoolFormModal({
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
|
- <PAProfileSection
|
|
|
|
|
|
|
+ <PrinterProfilesSection
|
|
|
formData={formData}
|
|
formData={formData}
|
|
|
- updateField={updateField}
|
|
|
|
|
printersWithCalibrations={resolvedCalibrations}
|
|
printersWithCalibrations={resolvedCalibrations}
|
|
|
|
|
+ filamentOptions={filamentOptions}
|
|
|
|
|
+ modelPresets={modelPresets}
|
|
|
|
|
+ setModelPresets={setModelPresets}
|
|
|
selectedProfiles={selectedProfiles}
|
|
selectedProfiles={selectedProfiles}
|
|
|
setSelectedProfiles={setSelectedProfiles}
|
|
setSelectedProfiles={setSelectedProfiles}
|
|
|
- expandedPrinters={expandedPrinters}
|
|
|
|
|
- setExpandedPrinters={setExpandedPrinters}
|
|
|
|
|
|
|
+ selectedGroupId={selectedGroupId}
|
|
|
|
|
+ setSelectedGroupId={setSelectedGroupId}
|
|
|
|
|
+ printerModels={printerModelsData}
|
|
|
|
|
+ isLoading={loadingCalibrations}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|