FilamentSection.tsx 22 KB

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