FilamentSection.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  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. // The identity fields a slicer preset can auto-fill.
  8. type PresetFilledField = 'material' | 'brand' | 'subtype';
  9. // Split a dropdown's options into the ones the catalog/presets pair with the
  10. // other field's current value and everything else (#1905). Suggestions are only
  11. // a sort order — nothing is ever hidden, so an unusual-but-real combination
  12. // (Elegoo ASA) stays one click away.
  13. function splitSuggested(options: string[], suggested: string[]): { top: string[]; rest: string[] } {
  14. if (suggested.length === 0) return { top: [], rest: options };
  15. const keys = new Set(suggested.map(s => s.toLowerCase()));
  16. return {
  17. top: options.filter(o => keys.has(o.toLowerCase())),
  18. rest: options.filter(o => !keys.has(o.toLowerCase())),
  19. };
  20. }
  21. function GroupHeading({ label }: { label: string }) {
  22. return (
  23. <div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-bambu-gray/70 bg-bambu-dark-tertiary/40">
  24. {label}
  25. </div>
  26. );
  27. }
  28. export function FilamentSection({
  29. formData,
  30. updateField,
  31. cloudAuthenticated,
  32. loadingCloudPresets,
  33. presetInputValue,
  34. setPresetInputValue,
  35. selectedPresetOption,
  36. filamentOptions,
  37. availableBrands,
  38. availableMaterials,
  39. suggestedBrands,
  40. suggestedMaterials,
  41. quickAdd,
  42. detailsRequired,
  43. quantity,
  44. onQuantityChange,
  45. errors,
  46. }: FilamentSectionProps) {
  47. const { t } = useTranslation();
  48. const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
  49. const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
  50. const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
  51. const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false);
  52. const [brandSearch, setBrandSearch] = useState('');
  53. const [subtypeSearch, setSubtypeSearch] = useState('');
  54. const [materialSearch, setMaterialSearch] = useState('');
  55. const [labelInput, setLabelInput] = useState(String(formData.label_weight));
  56. const [isLabelFocused, setIsLabelFocused] = useState(false);
  57. // Which identity fields the currently selected preset filled in (#1905).
  58. // Anything outside this set was set by the user (or loaded from the spool
  59. // being edited) and must survive picking a preset.
  60. const [presetFilled, setPresetFilled] = useState<Set<PresetFilledField>>(new Set());
  61. const presetRef = useRef<HTMLDivElement>(null);
  62. const brandRef = useRef<HTMLDivElement>(null);
  63. const subtypeRef = useRef<HTMLDivElement>(null);
  64. const materialRef = useRef<HTMLDivElement>(null);
  65. // Close dropdowns on outside click
  66. useEffect(() => {
  67. const handleClick = (e: MouseEvent) => {
  68. if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
  69. setPresetDropdownOpen(false);
  70. }
  71. if (materialRef.current && !materialRef.current.contains(e.target as Node)) {
  72. setMaterialDropdownOpen(false);
  73. }
  74. if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
  75. setBrandDropdownOpen(false);
  76. }
  77. if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {
  78. setSubtypeDropdownOpen(false);
  79. }
  80. };
  81. document.addEventListener('mousedown', handleClick);
  82. return () => document.removeEventListener('mousedown', handleClick);
  83. }, []);
  84. // Filtered presets based on search
  85. const filteredPresets = useMemo(() => {
  86. if (!presetInputValue) return filamentOptions;
  87. const search = presetInputValue.toLowerCase();
  88. return filamentOptions.filter(o =>
  89. o.displayName.toLowerCase().includes(search) ||
  90. o.code.toLowerCase().includes(search),
  91. );
  92. }, [filamentOptions, presetInputValue]);
  93. // Filtered brands
  94. const filteredBrands = useMemo(() => {
  95. if (!brandSearch) return availableBrands;
  96. const search = brandSearch.toLowerCase();
  97. const filtered = availableBrands.filter(b => b.toLowerCase().includes(search));
  98. // Sort: exact match first, then others
  99. return filtered.sort((a, b) => {
  100. const aExact = a.toLowerCase() === search;
  101. const bExact = b.toLowerCase() === search;
  102. if (aExact && !bExact) return -1;
  103. if (!aExact && bExact) return 1;
  104. return a.localeCompare(b);
  105. });
  106. }, [availableBrands, brandSearch]);
  107. const filteredVariants = useMemo(() => {
  108. if (!subtypeSearch) return KNOWN_VARIANTS;
  109. const search = subtypeSearch.toLowerCase();
  110. return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
  111. }, [subtypeSearch]);
  112. const filteredMaterials = useMemo(() => {
  113. if (!materialSearch) return availableMaterials;
  114. const search = materialSearch.toLowerCase();
  115. const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search));
  116. // Sort: exact match first, then others
  117. return filtered.sort((a, b) => {
  118. const aExact = a.toLowerCase() === search;
  119. const bExact = b.toLowerCase() === search;
  120. if (aExact && !bExact) return -1;
  121. if (!aExact && bExact) return 1;
  122. return a.localeCompare(b);
  123. });
  124. }, [materialSearch, availableMaterials]);
  125. const brandGroups = useMemo(
  126. () => splitSuggested(filteredBrands, suggestedBrands),
  127. [filteredBrands, suggestedBrands],
  128. );
  129. const materialGroups = useMemo(
  130. () => splitSuggested(filteredMaterials, suggestedMaterials),
  131. [filteredMaterials, suggestedMaterials],
  132. );
  133. useEffect(() => {
  134. if (!isLabelFocused) {
  135. setLabelInput(String(formData.label_weight));
  136. }
  137. }, [formData.label_weight, isLabelFocused]);
  138. // Handle preset selection
  139. const handlePresetSelect = (option: FilamentOption) => {
  140. updateField('slicer_filament', option.code);
  141. setPresetInputValue(option.displayName);
  142. setPresetDropdownOpen(false);
  143. // Auto-fill material, brand and subtype from the preset name — but only
  144. // where the value is still empty or was itself put there by the previously
  145. // selected preset (#1905). Overwriting unconditionally silently rewrote the
  146. // spool's own manufacturer ("Elegoo" → "Generic") whenever the user was
  147. // forced to assign a preset, so the spool vanished from where they filed it.
  148. // Switching between presets still replaces what the earlier one filled in.
  149. const parsed = parsePresetName(option.name);
  150. const filled = new Set<PresetFilledField>();
  151. const entries: [PresetFilledField, string][] = [
  152. ['material', parsed.material],
  153. ['brand', parsed.brand],
  154. ['subtype', parsed.variant],
  155. ];
  156. for (const [field, value] of entries) {
  157. if (!value) continue;
  158. if (formData[field] && !presetFilled.has(field)) continue;
  159. updateField(field, value);
  160. filled.add(field);
  161. }
  162. setPresetFilled(filled);
  163. };
  164. // A manual edit to an identity field takes it back out of the preset's hands.
  165. const updateIdentityField = (field: PresetFilledField, value: string) => {
  166. if (presetFilled.has(field)) {
  167. setPresetFilled(prev => {
  168. const next = new Set(prev);
  169. next.delete(field);
  170. return next;
  171. });
  172. }
  173. updateField(field, value);
  174. };
  175. const renderBrandOption = (brand: string) => (
  176. <button
  177. key={brand}
  178. type="button"
  179. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  180. formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  181. }`}
  182. onClick={() => {
  183. updateIdentityField('brand', brand);
  184. setBrandDropdownOpen(false);
  185. setBrandSearch('');
  186. }}
  187. >
  188. {brand}
  189. </button>
  190. );
  191. const renderMaterialOption = (material: string) => (
  192. <button
  193. key={material}
  194. type="button"
  195. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  196. formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  197. }`}
  198. onClick={() => {
  199. updateIdentityField('material', material);
  200. setMaterialDropdownOpen(false);
  201. setMaterialSearch('');
  202. }}
  203. >
  204. {material}
  205. </button>
  206. );
  207. return (
  208. <div className="space-y-4">
  209. {/* Cloud status indicator */}
  210. {!quickAdd && (
  211. <div className="flex items-center gap-2 text-xs text-bambu-gray">
  212. {loadingCloudPresets ? (
  213. <><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
  214. ) : cloudAuthenticated ? (
  215. <><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
  216. ) : (
  217. <><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
  218. )}
  219. </div>
  220. )}
  221. {/* Slicer Preset (autocomplete) — hidden in quick-add mode */}
  222. {!quickAdd && (
  223. <div>
  224. <label className="block text-sm font-medium text-bambu-gray mb-1">
  225. {t('inventory.slicerPreset')}{detailsRequired && ' *'}
  226. </label>
  227. <div className="relative" ref={presetRef}>
  228. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  229. <input
  230. type="text"
  231. 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"
  232. placeholder={t('inventory.searchPresets')}
  233. value={presetInputValue}
  234. onChange={(e) => {
  235. setPresetInputValue(e.target.value);
  236. setPresetDropdownOpen(true);
  237. }}
  238. onFocus={() => {
  239. setPresetDropdownOpen(true);
  240. setPresetInputValue('');
  241. }}
  242. />
  243. {presetDropdownOpen && (
  244. <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">
  245. {filteredPresets.length === 0 ? (
  246. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
  247. ) : (
  248. filteredPresets.map(option => (
  249. <button
  250. key={`${option.code}::${option.name}`}
  251. type="button"
  252. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${
  253. selectedPresetOption?.code === option.code
  254. ? 'bg-bambu-green/10 text-bambu-green'
  255. : 'text-white'
  256. }`}
  257. onClick={() => handlePresetSelect(option)}
  258. >
  259. {option.displayName}
  260. </button>
  261. ))
  262. )}
  263. </div>
  264. )}
  265. </div>
  266. {selectedPresetOption && (
  267. <div className="mt-1 text-xs text-bambu-gray">
  268. {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
  269. </div>
  270. )}
  271. {errors?.slicer_filament && (
  272. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{errors.slicer_filament}</p>
  273. )}
  274. </div>
  275. )}
  276. {/* Material */}
  277. <div>
  278. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
  279. <div className="relative" ref={materialRef}>
  280. <input
  281. type="text"
  282. 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"
  283. placeholder={t('inventory.selectMaterial')}
  284. value={materialDropdownOpen ? materialSearch : formData.material}
  285. onChange={(e) => {
  286. setMaterialSearch(e.target.value);
  287. setMaterialDropdownOpen(true);
  288. }}
  289. onFocus={() => {
  290. setMaterialDropdownOpen(true);
  291. setMaterialSearch('');
  292. }}
  293. />
  294. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  295. {materialDropdownOpen && (
  296. <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">
  297. {filteredMaterials.length === 0 ? (
  298. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  299. ) : (
  300. <>
  301. {materialGroups.top.length > 0 && (
  302. <>
  303. <GroupHeading label={t('inventory.suggestedOptions')} />
  304. {materialGroups.top.map(renderMaterialOption)}
  305. </>
  306. )}
  307. {materialGroups.rest.length > 0 && (
  308. <>
  309. {materialGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
  310. {materialGroups.rest.map(renderMaterialOption)}
  311. </>
  312. )}
  313. </>
  314. )}
  315. {/* Allow custom material */}
  316. {materialSearch && !filteredMaterials.includes(materialSearch) && (
  317. <button
  318. type="button"
  319. 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"
  320. onClick={() => {
  321. updateIdentityField('material', materialSearch);
  322. setMaterialDropdownOpen(false);
  323. setMaterialSearch('');
  324. }}
  325. >
  326. {t('inventory.useCustomMaterial', { material: materialSearch })}
  327. </button>
  328. )}
  329. </div>
  330. )}
  331. </div>
  332. {errors?.material && (
  333. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{errors.material}</p>
  334. )}
  335. </div>
  336. {/* Brand (dropdown with search) */}
  337. <div>
  338. <label className="block text-sm font-medium text-bambu-gray mb-1">
  339. {t('inventory.brand')}{detailsRequired && ' *'}
  340. </label>
  341. <div className="relative" ref={brandRef}>
  342. <input
  343. type="text"
  344. 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"
  345. placeholder={t('inventory.searchBrand')}
  346. value={brandDropdownOpen ? brandSearch : formData.brand}
  347. onChange={(e) => {
  348. setBrandSearch(e.target.value);
  349. setBrandDropdownOpen(true);
  350. }}
  351. onFocus={() => {
  352. setBrandDropdownOpen(true);
  353. setBrandSearch('');
  354. }}
  355. />
  356. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  357. {brandDropdownOpen && (
  358. <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">
  359. {filteredBrands.length === 0 ? (
  360. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  361. ) : (
  362. <>
  363. {brandGroups.top.length > 0 && (
  364. <>
  365. <GroupHeading label={t('inventory.suggestedOptions')} />
  366. {brandGroups.top.map(renderBrandOption)}
  367. </>
  368. )}
  369. {brandGroups.rest.length > 0 && (
  370. <>
  371. {brandGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
  372. {brandGroups.rest.map(renderBrandOption)}
  373. </>
  374. )}
  375. </>
  376. )}
  377. {/* Allow custom brand */}
  378. {brandSearch && !filteredBrands.includes(brandSearch) && (
  379. <button
  380. type="button"
  381. 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"
  382. onClick={() => {
  383. updateIdentityField('brand', brandSearch);
  384. setBrandDropdownOpen(false);
  385. setBrandSearch('');
  386. }}
  387. >
  388. {t('inventory.useCustomBrand', { brand: brandSearch })}
  389. </button>
  390. )}
  391. </div>
  392. )}
  393. </div>
  394. {errors?.brand && (
  395. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{errors.brand}</p>
  396. )}
  397. </div>
  398. {/* Variant / Subtype */}
  399. <div>
  400. <label className="block text-sm font-medium text-bambu-gray mb-1">
  401. {t('inventory.subtype')}{detailsRequired && ' *'}
  402. </label>
  403. <div className="relative" ref={subtypeRef}>
  404. <input
  405. type="text"
  406. value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
  407. onChange={(e) => {
  408. setSubtypeSearch(e.target.value);
  409. setSubtypeDropdownOpen(true);
  410. }}
  411. onFocus={() => {
  412. setSubtypeDropdownOpen(true);
  413. setSubtypeSearch('');
  414. }}
  415. placeholder="Basic, Matte, Silk..."
  416. 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"
  417. />
  418. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  419. {subtypeDropdownOpen && (
  420. <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">
  421. {filteredVariants.length === 0 ? (
  422. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  423. ) : (
  424. filteredVariants.map(variant => (
  425. <button
  426. key={variant}
  427. type="button"
  428. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  429. formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  430. }`}
  431. onClick={() => {
  432. updateIdentityField('subtype', variant);
  433. setSubtypeDropdownOpen(false);
  434. setSubtypeSearch('');
  435. }}
  436. >
  437. {variant}
  438. </button>
  439. ))
  440. )}
  441. {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
  442. <button
  443. type="button"
  444. 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"
  445. onClick={() => {
  446. updateIdentityField('subtype', subtypeSearch);
  447. setSubtypeDropdownOpen(false);
  448. setSubtypeSearch('');
  449. }}
  450. >
  451. {t('inventory.useCustomBrand', { brand: subtypeSearch })}
  452. </button>
  453. )}
  454. </div>
  455. )}
  456. </div>
  457. {errors?.subtype && (
  458. <p className="mt-1 text-xs text-red-700 dark:text-red-400">{errors.subtype}</p>
  459. )}
  460. </div>
  461. {/* Label Weight */}
  462. <div>
  463. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
  464. <div className="relative">
  465. <input
  466. type="number"
  467. 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"
  468. value={labelInput}
  469. min={0}
  470. onFocus={() => setIsLabelFocused(true)}
  471. onChange={(e) => setLabelInput(e.target.value)}
  472. onBlur={() => {
  473. setIsLabelFocused(false);
  474. const raw = labelInput.trim();
  475. const next = Number(raw);
  476. if (!raw || !Number.isFinite(next) || next < 0) {
  477. setLabelInput(String(formData.label_weight));
  478. return;
  479. }
  480. const rounded = Math.round(next);
  481. updateField('label_weight', rounded);
  482. setLabelInput(String(rounded));
  483. }}
  484. />
  485. <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
  486. </div>
  487. </div>
  488. {/* Quantity — only in quick-add mode */}
  489. {quickAdd && (
  490. <div>
  491. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
  492. <input
  493. type="number"
  494. 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"
  495. value={quantity}
  496. min={1}
  497. max={100}
  498. onChange={(e) => {
  499. const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
  500. onQuantityChange(val);
  501. }}
  502. />
  503. </div>
  504. )}
  505. </div>
  506. );
  507. }