mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 12:11:31 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user