|
|
@@ -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>
|
|
|
+ {!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>
|
|
|
+ )}
|
|
|
|
|
|
- {/* 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>
|
|
|
- ))
|
|
|
- )}
|
|
|
+ {/* 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>
|
|
|
+ {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>
|
|
|
- {selectedPresetOption && (
|
|
|
- <div className="mt-1 text-xs text-bambu-gray">
|
|
|
- {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ )}
|
|
|
|
|
|
{/* Material */}
|
|
|
<div>
|
|
|
@@ -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>
|
|
|
);
|
|
|
}
|