FilamentSection.tsx 22 KB

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