Add bulk spool addition and stock spools

Add Quick Add mode to the spool form for simplified entry (material +
color + weight only), and a quantity field (1-100) for creating multiple
identical spools at once. Stock spools (no slicer profile) are computed
rather than stored — any spool without slicer_filament shows an amber
"Stock" badge. New filter chips (All/Stock/Configured) on the inventory
page. Backend bulk endpoint creates N spools in one transaction.

Backend:
- SpoolBulkCreate schema with quantity validation (1-100)
- POST /inventory/spools/bulk endpoint

Frontend:
- Quick Add toggle in SpoolFormModal (create mode only)
- Quantity field in FilamentSection (both modes)
- bulkCreateSpools API method and bulkCreateMutation
- Stock column (hidden by default) and stock filter chips
- Inline error rendering moved into FilamentSection
- CellCtx extended with t() for translatable cell content

Tests:
- 13 backend tests (schema validation + endpoint logic)
- 10 frontend tests (validateForm quickAdd + UI behavior)

i18n: 6 locales (en, de, fr, it, ja, pt-BR)
Docs: CHANGELOG, README, website features, wiki inventory
This commit is contained in:
maziggy
2026-02-21 10:39:18 +01:00
parent 7d46b44561
commit 16fa84bc16
19 changed files with 883 additions and 262 deletions
+75 -31
View File
@@ -1,7 +1,7 @@
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 { X, Loader2, Save, Beaker, Palette, Zap } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
import { Button } from './Button';
@@ -38,6 +38,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('filament');
const [weightTouched, setWeightTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
// Cloud presets
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
@@ -275,6 +277,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
setFormData(defaultFormData);
setPresetInputValue('');
setSelectedProfiles(new Set());
setQuickAdd(false);
setQuantity(1);
}
setErrors({});
setActiveTab('filament');
@@ -321,6 +325,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
},
});
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 spool of newSpools) {
await saveKProfiles(spool.id);
}
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), '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]),
@@ -397,7 +419,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
if (!isOpen) return null;
const handleSubmit = () => {
const validation = validateForm(formData);
const validation = validateForm(formData, quickAdd);
if (!validation.isValid) {
setErrors(validation.errors);
// Switch to filament tab if there are errors there
@@ -435,12 +457,14 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
if (isEditing) {
updateMutation.mutate(data);
} else if (quantity > 1) {
bulkCreateMutation.mutate({ data, qty: quantity });
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -463,6 +487,32 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
</button>
</div>
{/* Quick Add toggle — only in create mode */}
{!isEditing && (
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-sm text-white">{t('inventory.quickAdd')}</span>
</div>
<button
type="button"
onClick={() => {
setQuickAdd(!quickAdd);
if (!quickAdd) setActiveTab('filament');
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
quickAdd ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
<button
@@ -476,22 +526,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
<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>
{!quickAdd && (
<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 */}
@@ -514,19 +566,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
filamentOptions={filamentOptions}
availableBrands={availableBrands}
availableMaterials={availableMaterials}
quickAdd={quickAdd}
quantity={quantity}
onQuantityChange={setQuantity}
errors={errors}
/>
{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>
)}
{errors.brand && (
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
)}
{errors.subtype && (
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
)}
</div>
{/* Color Section */}
@@ -16,6 +16,10 @@ export function FilamentSection({
filamentOptions,
availableBrands,
availableMaterials,
quickAdd,
quantity,
onQuantityChange,
errors,
}: FilamentSectionProps) {
const { t } = useTranslation();
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
@@ -119,67 +123,74 @@ export function FilamentSection({
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>
{!quickAdd && (
<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>
{selectedPresetOption && (
<div className="mt-1 text-xs text-bambu-gray">
{t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
)}
{/* Slicer Preset (autocomplete) — hidden in quick-add mode */}
{!quickAdd && (
<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>
)}
</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>
)}
{errors?.slicer_filament && (
<p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
)}
</div>
)}
{/* Material */}
<div>
@@ -239,125 +250,139 @@ export function FilamentSection({
</div>
)}
</div>
{errors?.material && (
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
)}
</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 => (
{/* Brand (dropdown with search) — hidden in quick-add mode */}
{!quickAdd && (
<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
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'
}`}
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', brand);
updateField('brand', brandSearch);
setBrandDropdownOpen(false);
setBrandSearch('');
}}
>
{brand}
{t('inventory.useCustomBrand', { brand: brandSearch })}
</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>
{errors?.brand && (
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
)}
</div>
</div>
)}
{/* Variant / Subtype */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
<div className="relative" ref={subtypeRef}>
<input
type="text"
value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
onChange={(e) => {
setSubtypeSearch(e.target.value);
setSubtypeDropdownOpen(true);
}}
onFocus={() => {
setSubtypeDropdownOpen(true);
setSubtypeSearch('');
}}
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"
/>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
{subtypeDropdownOpen && (
<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">
{filteredVariants.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filteredVariants.map(variant => (
{/* Variant / Subtype — hidden in quick-add mode */}
{!quickAdd && (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
<div className="relative" ref={subtypeRef}>
<input
type="text"
value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
onChange={(e) => {
setSubtypeSearch(e.target.value);
setSubtypeDropdownOpen(true);
}}
onFocus={() => {
setSubtypeDropdownOpen(true);
setSubtypeSearch('');
}}
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"
/>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
{subtypeDropdownOpen && (
<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">
{filteredVariants.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filteredVariants.map(variant => (
<button
key={variant}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
}`}
onClick={() => {
updateField('subtype', variant);
setSubtypeDropdownOpen(false);
setSubtypeSearch('');
}}
>
{variant}
</button>
))
)}
{subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
<button
key={variant}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
}`}
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('subtype', variant);
updateField('subtype', subtypeSearch);
setSubtypeDropdownOpen(false);
setSubtypeSearch('');
}}
>
{variant}
{t('inventory.useCustomBrand', { brand: subtypeSearch })}
</button>
))
)}
{subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
<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('subtype', subtypeSearch);
setSubtypeDropdownOpen(false);
setSubtypeSearch('');
}}
>
{t('inventory.useCustomBrand', { brand: subtypeSearch })}
</button>
)}
</div>
)}
</div>
)}
</div>
{errors?.subtype && (
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
)}
</div>
</div>
)}
{/* Label Weight */}
<div>
@@ -387,6 +412,22 @@ export function FilamentSection({
</div>
</div>
{/* Quantity */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
<input
type="number"
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"
value={quantity}
min={1}
max={100}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
onQuantityChange(val);
}}
/>
</div>
</div>
);
}
+15 -1
View File
@@ -89,6 +89,10 @@ export interface FilamentSectionProps extends SectionProps {
filamentOptions: FilamentOption[];
availableBrands: string[];
availableMaterials: string[];
quickAdd: boolean;
quantity: number;
onQuantityChange: (value: number) => void;
errors?: Partial<Record<keyof SpoolFormData, string>>;
}
// Color section props
@@ -119,9 +123,19 @@ export interface ValidationResult {
errors: Partial<Record<keyof SpoolFormData, string>>;
}
export function validateForm(formData: SpoolFormData): ValidationResult {
export function validateForm(formData: SpoolFormData, quickAdd = false): ValidationResult {
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
if (quickAdd) {
if (!formData.material) {
errors.material = 'Material is required';
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
if (!formData.slicer_filament) {
errors.slicer_filament = 'Slicer preset is required';
}