|
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react';
|
|
|
import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
|
|
import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import type { FilamentSectionProps, FilamentOption } from './types';
|
|
import type { FilamentSectionProps, FilamentOption } from './types';
|
|
|
-import { MATERIALS, KNOWN_VARIANTS } from './constants';
|
|
|
|
|
|
|
+import { KNOWN_VARIANTS } from './constants';
|
|
|
import { parsePresetName } from './utils';
|
|
import { parsePresetName } from './utils';
|
|
|
|
|
|
|
|
export function FilamentSection({
|
|
export function FilamentSection({
|
|
@@ -15,16 +15,22 @@ export function FilamentSection({
|
|
|
selectedPresetOption,
|
|
selectedPresetOption,
|
|
|
filamentOptions,
|
|
filamentOptions,
|
|
|
availableBrands,
|
|
availableBrands,
|
|
|
|
|
+ availableMaterials,
|
|
|
}: FilamentSectionProps) {
|
|
}: FilamentSectionProps) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
|
|
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
|
|
|
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
|
|
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
|
|
|
const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
|
|
const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
|
|
|
|
|
+ const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false);
|
|
|
const [brandSearch, setBrandSearch] = useState('');
|
|
const [brandSearch, setBrandSearch] = useState('');
|
|
|
const [subtypeSearch, setSubtypeSearch] = useState('');
|
|
const [subtypeSearch, setSubtypeSearch] = useState('');
|
|
|
|
|
+ const [materialSearch, setMaterialSearch] = useState('');
|
|
|
|
|
+ const [labelInput, setLabelInput] = useState(String(formData.label_weight));
|
|
|
|
|
+ const [isLabelFocused, setIsLabelFocused] = useState(false);
|
|
|
const presetRef = useRef<HTMLDivElement>(null);
|
|
const presetRef = useRef<HTMLDivElement>(null);
|
|
|
const brandRef = useRef<HTMLDivElement>(null);
|
|
const brandRef = useRef<HTMLDivElement>(null);
|
|
|
const subtypeRef = useRef<HTMLDivElement>(null);
|
|
const subtypeRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+ const materialRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
// Close dropdowns on outside click
|
|
// Close dropdowns on outside click
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
@@ -32,6 +38,9 @@ export function FilamentSection({
|
|
|
if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
|
|
if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
|
|
|
setPresetDropdownOpen(false);
|
|
setPresetDropdownOpen(false);
|
|
|
}
|
|
}
|
|
|
|
|
+ if (materialRef.current && !materialRef.current.contains(e.target as Node)) {
|
|
|
|
|
+ setMaterialDropdownOpen(false);
|
|
|
|
|
+ }
|
|
|
if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
|
|
if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
|
|
|
setBrandDropdownOpen(false);
|
|
setBrandDropdownOpen(false);
|
|
|
}
|
|
}
|
|
@@ -57,7 +66,15 @@ export function FilamentSection({
|
|
|
const filteredBrands = useMemo(() => {
|
|
const filteredBrands = useMemo(() => {
|
|
|
if (!brandSearch) return availableBrands;
|
|
if (!brandSearch) return availableBrands;
|
|
|
const search = brandSearch.toLowerCase();
|
|
const search = brandSearch.toLowerCase();
|
|
|
- return availableBrands.filter(b => b.toLowerCase().includes(search));
|
|
|
|
|
|
|
+ const filtered = availableBrands.filter(b => b.toLowerCase().includes(search));
|
|
|
|
|
+ // Sort: exact match first, then others
|
|
|
|
|
+ return filtered.sort((a, b) => {
|
|
|
|
|
+ const aExact = a.toLowerCase() === search;
|
|
|
|
|
+ const bExact = b.toLowerCase() === search;
|
|
|
|
|
+ if (aExact && !bExact) return -1;
|
|
|
|
|
+ if (!aExact && bExact) return 1;
|
|
|
|
|
+ return a.localeCompare(b);
|
|
|
|
|
+ });
|
|
|
}, [availableBrands, brandSearch]);
|
|
}, [availableBrands, brandSearch]);
|
|
|
|
|
|
|
|
const filteredVariants = useMemo(() => {
|
|
const filteredVariants = useMemo(() => {
|
|
@@ -66,6 +83,26 @@ export function FilamentSection({
|
|
|
return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
|
|
return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
|
|
|
}, [subtypeSearch]);
|
|
}, [subtypeSearch]);
|
|
|
|
|
|
|
|
|
|
+ const filteredMaterials = useMemo(() => {
|
|
|
|
|
+ if (!materialSearch) return availableMaterials;
|
|
|
|
|
+ const search = materialSearch.toLowerCase();
|
|
|
|
|
+ const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search));
|
|
|
|
|
+ // Sort: exact match first, then others
|
|
|
|
|
+ return filtered.sort((a, b) => {
|
|
|
|
|
+ const aExact = a.toLowerCase() === search;
|
|
|
|
|
+ const bExact = b.toLowerCase() === search;
|
|
|
|
|
+ if (aExact && !bExact) return -1;
|
|
|
|
|
+ if (!aExact && bExact) return 1;
|
|
|
|
|
+ return a.localeCompare(b);
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [materialSearch, availableMaterials]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isLabelFocused) {
|
|
|
|
|
+ setLabelInput(String(formData.label_weight));
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [formData.label_weight, isLabelFocused]);
|
|
|
|
|
+
|
|
|
// Handle preset selection
|
|
// Handle preset selection
|
|
|
const handlePresetSelect = (option: FilamentOption) => {
|
|
const handlePresetSelect = (option: FilamentOption) => {
|
|
|
updateField('slicer_filament', option.code);
|
|
updateField('slicer_filament', option.code);
|
|
@@ -147,18 +184,62 @@ export function FilamentSection({
|
|
|
{/* Material */}
|
|
{/* Material */}
|
|
|
<div>
|
|
<div>
|
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
|
|
|
- <select
|
|
|
|
|
- value={formData.material}
|
|
|
|
|
- onChange={(e) => updateField('material', e.target.value)}
|
|
|
|
|
- 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"
|
|
|
|
|
- >
|
|
|
|
|
- <option value="">{t('inventory.selectMaterial')}</option>
|
|
|
|
|
- {MATERIALS.map((m) => (
|
|
|
|
|
- <option key={m} value={m}>{m}</option>
|
|
|
|
|
- ))}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ <div className="relative" ref={materialRef}>
|
|
|
|
|
+ <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.selectMaterial')}
|
|
|
|
|
+ value={materialDropdownOpen ? materialSearch : formData.material}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ setMaterialSearch(e.target.value);
|
|
|
|
|
+ setMaterialDropdownOpen(true);
|
|
|
|
|
+ }}
|
|
|
|
|
+ onFocus={() => {
|
|
|
|
|
+ setMaterialDropdownOpen(true);
|
|
|
|
|
+ setMaterialSearch('');
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
|
|
|
|
|
+ {materialDropdownOpen && (
|
|
|
|
|
+ <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">
|
|
|
|
|
+ {filteredMaterials.length === 0 ? (
|
|
|
|
|
+ <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ filteredMaterials.map((material) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={material}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
|
|
|
|
+ formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ updateField('material', material);
|
|
|
|
|
+ setMaterialDropdownOpen(false);
|
|
|
|
|
+ setMaterialSearch('');
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {material}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))
|
|
|
|
|
+ )}
|
|
|
|
|
+ {/* Allow custom material */}
|
|
|
|
|
+ {materialSearch && !filteredMaterials.includes(materialSearch) && (
|
|
|
|
|
+ <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('material', materialSearch);
|
|
|
|
|
+ setMaterialDropdownOpen(false);
|
|
|
|
|
+ setMaterialSearch('');
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('inventory.useCustomMaterial', { material: materialSearch })}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
{/* Brand (dropdown with search) */}
|
|
{/* Brand (dropdown with search) */}
|
|
|
<div>
|
|
<div>
|
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
|
@@ -284,13 +365,28 @@ export function FilamentSection({
|
|
|
<div className="relative">
|
|
<div className="relative">
|
|
|
<input
|
|
<input
|
|
|
type="number"
|
|
type="number"
|
|
|
- value={formData.label_weight}
|
|
|
|
|
- onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
|
|
|
|
|
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
|
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
|
|
|
|
+ value={labelInput}
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ onFocus={() => setIsLabelFocused(true)}
|
|
|
|
|
+ onChange={(e) => setLabelInput(e.target.value)}
|
|
|
|
|
+ onBlur={() => {
|
|
|
|
|
+ setIsLabelFocused(false);
|
|
|
|
|
+ const raw = labelInput.trim();
|
|
|
|
|
+ const next = Number(raw);
|
|
|
|
|
+ if (!raw || !Number.isFinite(next) || next < 0) {
|
|
|
|
|
+ setLabelInput(String(formData.label_weight));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const rounded = Math.round(next);
|
|
|
|
|
+ updateField('label_weight', rounded);
|
|
|
|
|
+ setLabelInput(String(rounded));
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
|
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|