mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
This commit is contained in:
@@ -2,25 +2,22 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
|
||||
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
||||
import { defaultFormData, validateForm } from './spool-form/types';
|
||||
import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
|
||||
import { MATERIALS } from './spool-form/constants';
|
||||
import { FilamentSection } from './spool-form/FilamentSection';
|
||||
import { ColorSection } from './spool-form/ColorSection';
|
||||
import { AdditionalSection } from './spool-form/AdditionalSection';
|
||||
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
||||
import { PAProfileSection } from './spool-form/PAProfileSection';
|
||||
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
||||
|
||||
type TabId = 'filament' | 'pa-profile';
|
||||
|
||||
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
||||
|
||||
interface SpoolFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -28,10 +25,6 @@ interface SpoolFormModalProps {
|
||||
printersWithCalibrations?: PrinterWithCalibrations[];
|
||||
currencySymbol: string;
|
||||
onSpoolsCreated?: (spools: InventorySpool[]) => void;
|
||||
/** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
|
||||
spoolmanMode?: boolean;
|
||||
/** Query key to invalidate after mutations (differs for Spoolman vs local). */
|
||||
spoolsQueryKey?: string[];
|
||||
}
|
||||
|
||||
export function SpoolFormModal({
|
||||
@@ -41,8 +34,6 @@ export function SpoolFormModal({
|
||||
printersWithCalibrations = [],
|
||||
currencySymbol,
|
||||
onSpoolsCreated,
|
||||
spoolmanMode = false,
|
||||
spoolsQueryKey = ['inventory-spools'],
|
||||
}: SpoolFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -55,7 +46,6 @@ export function SpoolFormModal({
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
|
||||
const [activeTab, setActiveTab] = useState<TabId>('filament');
|
||||
const [weightTouched, setWeightTouched] = useState(false);
|
||||
const [storageLocationTouched, setStorageLocationTouched] = useState(false);
|
||||
const [quickAdd, setQuickAdd] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
@@ -88,17 +78,9 @@ export function SpoolFormModal({
|
||||
: fetchedCalibrations;
|
||||
|
||||
// Count selected PA profiles for tab badge
|
||||
const selectedProfileCount = selectedProfiles.size;
|
||||
|
||||
// Fetch Spoolman filament catalog when in Spoolman mode
|
||||
// retry:false — Spoolman may be intentionally disabled (400); don't flood the server
|
||||
const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
|
||||
queryKey: ['spoolman-inventory-filaments'],
|
||||
queryFn: () => api.getSpoolmanInventoryFilaments(),
|
||||
enabled: spoolmanMode && isOpen,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const selectedProfileCount = useMemo(() => {
|
||||
return selectedProfiles.size;
|
||||
}, [selectedProfiles]);
|
||||
|
||||
// Load recent colors on mount
|
||||
useEffect(() => {
|
||||
@@ -125,9 +107,7 @@ export function SpoolFormModal({
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
if (!spoolmanMode) {
|
||||
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
|
||||
}
|
||||
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
|
||||
api.getColorCatalog().then(setColorCatalog).catch(console.error);
|
||||
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
|
||||
|
||||
@@ -171,13 +151,6 @@ export function SpoolFormModal({
|
||||
})();
|
||||
}
|
||||
}
|
||||
// The effect intentionally depends only on `isOpen` (and the prop-side
|
||||
// calibration count) — re-running on every spoolmanMode toggle would
|
||||
// race the in-flight async fetches with unmount/teardown and emit
|
||||
// "test environment was torn down" errors in vitest. spoolmanMode only
|
||||
// gates a single fetch (getSpoolCatalog) which is cheap enough to skip
|
||||
// when the modal opens in Spoolman mode.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, printersWithCalibrations.length]);
|
||||
|
||||
// Build filament options: cloud → local → fallback
|
||||
@@ -307,8 +280,6 @@ export function SpoolFormModal({
|
||||
cost_per_kg: spool.cost_per_kg ?? null,
|
||||
category: spool.category || '',
|
||||
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
|
||||
storage_location: spool.storage_location || '',
|
||||
spoolman_filament_id: null,
|
||||
});
|
||||
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
|
||||
|
||||
@@ -334,7 +305,6 @@ export function SpoolFormModal({
|
||||
setErrors({});
|
||||
setActiveTab('filament');
|
||||
setWeightTouched(false);
|
||||
setStorageLocationTouched(false);
|
||||
}
|
||||
}, [isOpen, spool]);
|
||||
|
||||
@@ -347,166 +317,83 @@ export function SpoolFormModal({
|
||||
|
||||
// Update field helper
|
||||
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
||||
const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
|
||||
if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
|
||||
showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
|
||||
}
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
|
||||
? { spoolman_filament_id: null }
|
||||
: {}),
|
||||
}));
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
if (key === 'weight_used') setWeightTouched(true);
|
||||
if (key === 'storage_location') setStorageLocationTouched(true);
|
||||
if (errors[key]) {
|
||||
setErrors(prev => ({ ...prev, [key]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Prefill form from a Spoolman filament catalog entry
|
||||
// subtype extraction mirrors _spoolman_helpers.py logic
|
||||
const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
|
||||
const material = filament.material || '';
|
||||
const name = filament.name || '';
|
||||
const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
|
||||
const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
|
||||
// Guard against short/malformed hex values — must be exactly 6 hex chars
|
||||
const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080';
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
spoolman_filament_id: filament.id,
|
||||
material,
|
||||
subtype,
|
||||
brand: filament.vendor?.name || '',
|
||||
rgba: `${colorHex}FF`,
|
||||
color_name: filament.color_name || '',
|
||||
label_weight: filament.weight ?? prev.label_weight,
|
||||
}));
|
||||
showToast(t('inventory.spoolmanFilamentSelected'), 'success');
|
||||
};
|
||||
|
||||
// Handle color selection
|
||||
const handleColorUsed = (color: ColorPreset) => {
|
||||
setRecentColors(prev => saveRecentColor(color, prev));
|
||||
};
|
||||
|
||||
// Mutations – dispatch to Spoolman proxy or local inventory based on mode
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
spoolmanMode
|
||||
? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
|
||||
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
||||
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
||||
onSuccess: async (newSpool) => {
|
||||
if (newSpool?.id) {
|
||||
const ok = await saveKProfiles(newSpool.id);
|
||||
if (!ok) return;
|
||||
// Save K-profiles if any selected
|
||||
if (selectedProfiles.size > 0 && newSpool?.id) {
|
||||
await saveKProfiles(newSpool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
|
||||
showToast(t('inventory.spoolCreated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||||
} else {
|
||||
showToast(t('inventory.saveFailed'), 'error');
|
||||
}
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const bulkCreateMutation = useMutation<
|
||||
SpoolmanBulkCreateResult | InventorySpool[],
|
||||
Error,
|
||||
{ data: Record<string, unknown>; qty: number }
|
||||
>({
|
||||
mutationFn: ({ data, qty }) =>
|
||||
spoolmanMode
|
||||
? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
|
||||
: api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
|
||||
onSuccess: async (result) => {
|
||||
// Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
|
||||
// Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
|
||||
// the duck-type check ('created' in result) before any property access.
|
||||
const spoolmanResult = (spoolmanMode && 'created' in result)
|
||||
? (result as unknown as SpoolmanBulkCreateResult)
|
||||
: null;
|
||||
const createdSpools: InventorySpool[] = spoolmanResult
|
||||
? spoolmanResult.created
|
||||
: (result as InventorySpool[]);
|
||||
|
||||
const bulkCreateMutation = useMutation({
|
||||
mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
|
||||
api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
|
||||
onSuccess: async (newSpools) => {
|
||||
if (selectedProfiles.size > 0) {
|
||||
for (const s of createdSpools) {
|
||||
await saveKProfiles(s.id);
|
||||
for (const spool of newSpools) {
|
||||
await saveKProfiles(spool.id);
|
||||
}
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
||||
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
|
||||
if (spoolmanResult && spoolmanResult.failed_count > 0) {
|
||||
showToast(
|
||||
t('inventory.spoolsPartiallyCreated', {
|
||||
created: createdSpools.length,
|
||||
total: spoolmanResult.requested_count,
|
||||
}),
|
||||
'warning',
|
||||
);
|
||||
} else {
|
||||
showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated(newSpools);
|
||||
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||||
} else {
|
||||
showToast(t('inventory.saveFailed'), 'error');
|
||||
}
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
spoolmanMode
|
||||
? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
|
||||
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||||
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
// Save K-profiles
|
||||
if (spool?.id) {
|
||||
const ok = await saveKProfiles(spool.id);
|
||||
if (!ok) return;
|
||||
await saveKProfiles(spool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.spoolUpdated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||||
} else {
|
||||
showToast(t('inventory.saveFailed'), 'error');
|
||||
}
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTagMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (spoolmanMode) {
|
||||
return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
|
||||
}
|
||||
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
|
||||
},
|
||||
mutationFn: () =>
|
||||
api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
showToast(t('inventory.spoolmanUnreachable'), 'error');
|
||||
} else {
|
||||
showToast(t('inventory.tagClearFailed'), 'error');
|
||||
}
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -555,29 +442,26 @@ 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;
|
||||
|
||||
// Save K-profiles for selected calibrations
|
||||
const saveKProfiles = async (spoolId: number) => {
|
||||
if (selectedProfiles.size === 0) {
|
||||
// Clear existing K-profiles
|
||||
try {
|
||||
await saveApi(spoolId, []);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to save K-profiles:', e);
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
await api.saveSpoolKProfiles(spoolId, []);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles: SpoolKProfileInput[] = [];
|
||||
let dropped = 0;
|
||||
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);
|
||||
@@ -591,32 +475,17 @@ export function SpoolFormModal({
|
||||
cali_idx: cal.cali_idx,
|
||||
setting_id: cal.setting_id || null,
|
||||
});
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (dropped > 0) {
|
||||
console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (profiles.length > 0) {
|
||||
try {
|
||||
await saveApi(spoolId, profiles);
|
||||
return true;
|
||||
await api.saveSpoolKProfiles(spoolId, profiles);
|
||||
} catch (e) {
|
||||
console.error('Failed to save K-profiles:', e);
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
@@ -632,9 +501,10 @@ export function SpoolFormModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validation = validateForm(formData, quickAdd, spoolmanMode);
|
||||
const validation = validateForm(formData, quickAdd);
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
// Switch to filament tab if there are errors there
|
||||
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
|
||||
setActiveTab('filament');
|
||||
}
|
||||
@@ -645,7 +515,7 @@ export function SpoolFormModal({
|
||||
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
material: formData.material || null,
|
||||
material: formData.material,
|
||||
subtype: formData.subtype || null,
|
||||
brand: formData.brand || null,
|
||||
color_name: formData.color_name || null,
|
||||
@@ -653,7 +523,8 @@ export function SpoolFormModal({
|
||||
extra_colors: formData.extra_colors || null,
|
||||
effect_type: formData.effect_type || null,
|
||||
label_weight: formData.label_weight,
|
||||
...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
|
||||
core_weight: formData.core_weight,
|
||||
core_weight_catalog_id: formData.core_weight_catalog_id,
|
||||
slicer_filament: formData.slicer_filament || null,
|
||||
slicer_filament_name: presetName,
|
||||
nozzle_temp_min: null,
|
||||
@@ -662,7 +533,6 @@ export function SpoolFormModal({
|
||||
cost_per_kg: formData.cost_per_kg,
|
||||
category: formData.category.trim() || null,
|
||||
low_stock_threshold_pct: formData.low_stock_threshold_pct,
|
||||
...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
|
||||
};
|
||||
|
||||
// Only send weight_used when creating or when explicitly changed by the user.
|
||||
@@ -671,13 +541,6 @@ export function SpoolFormModal({
|
||||
data.weight_used = formData.weight_used;
|
||||
}
|
||||
|
||||
// Only send storage_location when creating or when explicitly changed by the user.
|
||||
// This prevents the modal round-trip from overwriting the Spoolman location field
|
||||
// with a stale cached value when the user saves without touching this field.
|
||||
if (!isEditing || storageLocationTouched) {
|
||||
data.storage_location = formData.storage_location || null;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(data);
|
||||
} else if (quantity > 1) {
|
||||
@@ -773,22 +636,6 @@ export function SpoolFormModal({
|
||||
<div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
|
||||
{activeTab === 'filament' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
|
||||
{spoolmanMode && !isEditing && (
|
||||
<div>
|
||||
{filamentsError ? (
|
||||
<p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
|
||||
) : (
|
||||
<SpoolmanFilamentPicker
|
||||
filaments={spoolmanFilaments}
|
||||
isLoading={isLoadingFilaments}
|
||||
selectedId={formData.spoolman_filament_id}
|
||||
onSelect={handleFilamentSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament Info Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
|
||||
@@ -838,12 +685,11 @@ export function SpoolFormModal({
|
||||
currencySymbol={currencySymbol}
|
||||
availableCategories={availableCategories}
|
||||
globalLowStockThreshold={globalLowStockThreshold}
|
||||
spoolmanMode={spoolmanMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
|
||||
{isEditing && spool && !spoolmanMode && (
|
||||
{/* Usage History (only when editing) */}
|
||||
{isEditing && spool && (
|
||||
<div>
|
||||
<SpoolUsageHistory spoolId={spool.id} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user