FilamentSection.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import { useState, useRef, useEffect, useMemo } from 'react';
  2. import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type { FilamentSectionProps, FilamentOption } from './types';
  5. import { KNOWN_VARIANTS } from './constants';
  6. import { parsePresetName } from './utils';
  7. export function FilamentSection({
  8. formData,
  9. updateField,
  10. cloudAuthenticated,
  11. loadingCloudPresets,
  12. presetInputValue,
  13. setPresetInputValue,
  14. selectedPresetOption,
  15. filamentOptions,
  16. availableBrands,
  17. availableMaterials,
  18. quickAdd,
  19. quantity,
  20. onQuantityChange,
  21. errors,
  22. }: FilamentSectionProps) {
  23. const { t } = useTranslation();
  24. const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
  25. const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
  26. const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
  27. const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false);
  28. const [brandSearch, setBrandSearch] = useState('');
  29. const [subtypeSearch, setSubtypeSearch] = useState('');
  30. const [materialSearch, setMaterialSearch] = useState('');
  31. const [labelInput, setLabelInput] = useState(String(formData.label_weight));
  32. const [isLabelFocused, setIsLabelFocused] = useState(false);
  33. const presetRef = useRef<HTMLDivElement>(null);
  34. const brandRef = useRef<HTMLDivElement>(null);
  35. const subtypeRef = useRef<HTMLDivElement>(null);
  36. const materialRef = useRef<HTMLDivElement>(null);
  37. // Close dropdowns on outside click
  38. useEffect(() => {
  39. const handleClick = (e: MouseEvent) => {
  40. if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
  41. setPresetDropdownOpen(false);
  42. }
  43. if (materialRef.current && !materialRef.current.contains(e.target as Node)) {
  44. setMaterialDropdownOpen(false);
  45. }
  46. if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
  47. setBrandDropdownOpen(false);
  48. }
  49. if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {
  50. setSubtypeDropdownOpen(false);
  51. }
  52. };
  53. document.addEventListener('mousedown', handleClick);
  54. return () => document.removeEventListener('mousedown', handleClick);
  55. }, []);
  56. // Filtered presets based on search
  57. const filteredPresets = useMemo(() => {
  58. if (!presetInputValue) return filamentOptions;
  59. const search = presetInputValue.toLowerCase();
  60. return filamentOptions.filter(o =>
  61. o.displayName.toLowerCase().includes(search) ||
  62. o.code.toLowerCase().includes(search),
  63. );
  64. }, [filamentOptions, presetInputValue]);
  65. // Filtered brands
  66. const filteredBrands = useMemo(() => {
  67. if (!brandSearch) return availableBrands;
  68. const search = brandSearch.toLowerCase();
  69. const filtered = availableBrands.filter(b => b.toLowerCase().includes(search));
  70. // Sort: exact match first, then others
  71. return filtered.sort((a, b) => {
  72. const aExact = a.toLowerCase() === search;
  73. const bExact = b.toLowerCase() === search;
  74. if (aExact && !bExact) return -1;
  75. if (!aExact && bExact) return 1;
  76. return a.localeCompare(b);
  77. });
  78. }, [availableBrands, brandSearch]);
  79. const filteredVariants = useMemo(() => {
  80. if (!subtypeSearch) return KNOWN_VARIANTS;
  81. const search = subtypeSearch.toLowerCase();
  82. return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
  83. }, [subtypeSearch]);
  84. const filteredMaterials = useMemo(() => {
  85. if (!materialSearch) return availableMaterials;
  86. const search = materialSearch.toLowerCase();
  87. const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search));
  88. // Sort: exact match first, then others
  89. return filtered.sort((a, b) => {
  90. const aExact = a.toLowerCase() === search;
  91. const bExact = b.toLowerCase() === search;
  92. if (aExact && !bExact) return -1;
  93. if (!aExact && bExact) return 1;
  94. return a.localeCompare(b);
  95. });
  96. }, [materialSearch, availableMaterials]);
  97. useEffect(() => {
  98. if (!isLabelFocused) {
  99. setLabelInput(String(formData.label_weight));
  100. }
  101. }, [formData.label_weight, isLabelFocused]);
  102. // Handle preset selection
  103. const handlePresetSelect = (option: FilamentOption) => {
  104. updateField('slicer_filament', option.code);
  105. setPresetInputValue(option.displayName);
  106. setPresetDropdownOpen(false);
  107. // Auto-fill material, brand, subtype from preset name
  108. const parsed = parsePresetName(option.name);
  109. if (parsed.material) updateField('material', parsed.material);
  110. if (parsed.brand) updateField('brand', parsed.brand);
  111. if (parsed.variant) updateField('subtype', parsed.variant);
  112. };
  113. return (
  114. <div className="space-y-4">
  115. {/* Cloud status indicator */}
  116. {!quickAdd && (
  117. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  118. {loadingCloudPresets ? (
  119. <><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
  120. ) : cloudAuthenticated ? (
  121. <><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
  122. ) : (
  123. <><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
  124. )}
  125. </div>
  126. )}
  127. {/* Slicer Preset (autocomplete) — hidden in quick-add mode */}
  128. {!quickAdd && (
  129. <div>
  130. <label className="block text-sm font-medium text-bambu-gray mb-1">
  131. {t('inventory.slicerPreset')} *
  132. </label>
  133. <div className="relative" ref={presetRef}>
  134. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  135. <input
  136. type="text"
  137. 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"
  138. placeholder={t('inventory.searchPresets')}
  139. value={presetInputValue}
  140. onChange={(e) => {
  141. setPresetInputValue(e.target.value);
  142. setPresetDropdownOpen(true);
  143. }}
  144. onFocus={() => {
  145. setPresetDropdownOpen(true);
  146. setPresetInputValue('');
  147. }}
  148. />
  149. {presetDropdownOpen && (
  150. <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">
  151. {filteredPresets.length === 0 ? (
  152. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
  153. ) : (
  154. filteredPresets.map(option => (
  155. <button
  156. key={`${option.code}::${option.name}`}
  157. type="button"
  158. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${
  159. selectedPresetOption?.code === option.code
  160. ? 'bg-bambu-green/10 text-bambu-green'
  161. : 'text-white'
  162. }`}
  163. onClick={() => handlePresetSelect(option)}
  164. >
  165. {option.displayName}
  166. </button>
  167. ))
  168. )}
  169. </div>
  170. )}
  171. </div>
  172. {selectedPresetOption && (
  173. <div className="mt-1 text-xs text-bambu-gray">
  174. {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
  175. </div>
  176. )}
  177. {errors?.slicer_filament && (
  178. <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
  179. )}
  180. </div>
  181. )}
  182. {/* Material */}
  183. <div>
  184. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
  185. <div className="relative" ref={materialRef}>
  186. <input
  187. type="text"
  188. 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"
  189. placeholder={t('inventory.selectMaterial')}
  190. value={materialDropdownOpen ? materialSearch : formData.material}
  191. onChange={(e) => {
  192. setMaterialSearch(e.target.value);
  193. setMaterialDropdownOpen(true);
  194. }}
  195. onFocus={() => {
  196. setMaterialDropdownOpen(true);
  197. setMaterialSearch('');
  198. }}
  199. />
  200. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  201. {materialDropdownOpen && (
  202. <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">
  203. {filteredMaterials.length === 0 ? (
  204. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  205. ) : (
  206. filteredMaterials.map((material) => (
  207. <button
  208. key={material}
  209. type="button"
  210. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  211. formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  212. }`}
  213. onClick={() => {
  214. updateField('material', material);
  215. setMaterialDropdownOpen(false);
  216. setMaterialSearch('');
  217. }}
  218. >
  219. {material}
  220. </button>
  221. ))
  222. )}
  223. {/* Allow custom material */}
  224. {materialSearch && !filteredMaterials.includes(materialSearch) && (
  225. <button
  226. type="button"
  227. 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"
  228. onClick={() => {
  229. updateField('material', materialSearch);
  230. setMaterialDropdownOpen(false);
  231. setMaterialSearch('');
  232. }}
  233. >
  234. {t('inventory.useCustomMaterial', { material: materialSearch })}
  235. </button>
  236. )}
  237. </div>
  238. )}
  239. </div>
  240. {errors?.material && (
  241. <p className="mt-1 text-xs text-red-400">{errors.material}</p>
  242. )}
  243. </div>
  244. {/* Brand (dropdown with search) */}
  245. <div>
  246. <label className="block text-sm font-medium text-bambu-gray mb-1">
  247. {t('inventory.brand')}{!quickAdd && ' *'}
  248. </label>
  249. <div className="relative" ref={brandRef}>
  250. <input
  251. type="text"
  252. 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"
  253. placeholder={t('inventory.searchBrand')}
  254. value={brandDropdownOpen ? brandSearch : formData.brand}
  255. onChange={(e) => {
  256. setBrandSearch(e.target.value);
  257. setBrandDropdownOpen(true);
  258. }}
  259. onFocus={() => {
  260. setBrandDropdownOpen(true);
  261. setBrandSearch('');
  262. }}
  263. />
  264. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  265. {brandDropdownOpen && (
  266. <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">
  267. {filteredBrands.length === 0 ? (
  268. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  269. ) : (
  270. filteredBrands.map(brand => (
  271. <button
  272. key={brand}
  273. type="button"
  274. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  275. formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  276. }`}
  277. onClick={() => {
  278. updateField('brand', brand);
  279. setBrandDropdownOpen(false);
  280. setBrandSearch('');
  281. }}
  282. >
  283. {brand}
  284. </button>
  285. ))
  286. )}
  287. {/* Allow custom brand */}
  288. {brandSearch && !filteredBrands.includes(brandSearch) && (
  289. <button
  290. type="button"
  291. 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"
  292. onClick={() => {
  293. updateField('brand', brandSearch);
  294. setBrandDropdownOpen(false);
  295. setBrandSearch('');
  296. }}
  297. >
  298. {t('inventory.useCustomBrand', { brand: brandSearch })}
  299. </button>
  300. )}
  301. </div>
  302. )}
  303. </div>
  304. {errors?.brand && (
  305. <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
  306. )}
  307. </div>
  308. {/* Variant / Subtype */}
  309. <div>
  310. <label className="block text-sm font-medium text-bambu-gray mb-1">
  311. {t('inventory.subtype')}{!quickAdd && ' *'}
  312. </label>
  313. <div className="relative" ref={subtypeRef}>
  314. <input
  315. type="text"
  316. value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
  317. onChange={(e) => {
  318. setSubtypeSearch(e.target.value);
  319. setSubtypeDropdownOpen(true);
  320. }}
  321. onFocus={() => {
  322. setSubtypeDropdownOpen(true);
  323. setSubtypeSearch('');
  324. }}
  325. placeholder="Basic, Matte, Silk..."
  326. 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"
  327. />
  328. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  329. {subtypeDropdownOpen && (
  330. <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">
  331. {filteredVariants.length === 0 ? (
  332. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  333. ) : (
  334. filteredVariants.map(variant => (
  335. <button
  336. key={variant}
  337. type="button"
  338. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  339. formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  340. }`}
  341. onClick={() => {
  342. updateField('subtype', variant);
  343. setSubtypeDropdownOpen(false);
  344. setSubtypeSearch('');
  345. }}
  346. >
  347. {variant}
  348. </button>
  349. ))
  350. )}
  351. {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
  352. <button
  353. type="button"
  354. 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"
  355. onClick={() => {
  356. updateField('subtype', subtypeSearch);
  357. setSubtypeDropdownOpen(false);
  358. setSubtypeSearch('');
  359. }}
  360. >
  361. {t('inventory.useCustomBrand', { brand: subtypeSearch })}
  362. </button>
  363. )}
  364. </div>
  365. )}
  366. </div>
  367. {errors?.subtype && (
  368. <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
  369. )}
  370. </div>
  371. {/* Label Weight */}
  372. <div>
  373. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
  374. <div className="relative">
  375. <input
  376. type="number"
  377. 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"
  378. value={labelInput}
  379. min={0}
  380. onFocus={() => setIsLabelFocused(true)}
  381. onChange={(e) => setLabelInput(e.target.value)}
  382. onBlur={() => {
  383. setIsLabelFocused(false);
  384. const raw = labelInput.trim();
  385. const next = Number(raw);
  386. if (!raw || !Number.isFinite(next) || next < 0) {
  387. setLabelInput(String(formData.label_weight));
  388. return;
  389. }
  390. const rounded = Math.round(next);
  391. updateField('label_weight', rounded);
  392. setLabelInput(String(rounded));
  393. }}
  394. />
  395. <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
  396. </div>
  397. </div>
  398. {/* Quantity — only in quick-add mode */}
  399. {quickAdd && (
  400. <div>
  401. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
  402. <input
  403. type="number"
  404. 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"
  405. value={quantity}
  406. min={1}
  407. max={100}
  408. onChange={(e) => {
  409. const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
  410. onQuantityChange(val);
  411. }}
  412. />
  413. </div>
  414. )}
  415. </div>
  416. );
  417. }