BulkEditSpoolsModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery } from '@tanstack/react-query';
  4. import { X, Loader2, ChevronDown } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { InventorySpool } from '../api/client';
  7. import { Button } from './Button';
  8. import { MATERIALS, DEFAULT_BRANDS, KNOWN_VARIANTS } from './spool-form/constants';
  9. import { buildFilamentOptions } from './spool-form/utils';
  10. /** Subset of InventorySpool fields the bulk-edit modal can patch.
  11. * Mirrors the agreed set discussed for #1795 — flat per-spool fields only;
  12. * K-profile editing stays per-spool.
  13. */
  14. type EditableField =
  15. | 'material'
  16. | 'subtype'
  17. | 'brand'
  18. | 'color_name'
  19. | 'rgba'
  20. | 'location_id'
  21. | 'slicer_filament_name'
  22. | 'slicer_filament'
  23. | 'cost_per_kg'
  24. | 'note'
  25. | 'label_weight'
  26. | 'core_weight'
  27. | 'category'
  28. | 'low_stock_threshold_pct';
  29. type FieldSpec = {
  30. id: EditableField;
  31. /** searchable = custom dropdown with text input + filtered options (free text allowed).
  32. * searchableClosed = same but no custom value (must pick from list — used for storage_location).
  33. * text = plain text input.
  34. * number = number input.
  35. * color = colour picker + hex input.
  36. * textarea = multi-line text. */
  37. type: 'searchable' | 'searchableClosed' | 'text' | 'number' | 'color' | 'textarea';
  38. labelKey: string;
  39. min?: number;
  40. max?: number;
  41. step?: number;
  42. /** Hex pattern for the rgba field. */
  43. pattern?: string;
  44. };
  45. const FIELDS: FieldSpec[] = [
  46. { id: 'material', type: 'searchable', labelKey: 'inventory.material' },
  47. { id: 'subtype', type: 'searchable', labelKey: 'inventory.subtype' },
  48. { id: 'brand', type: 'searchable', labelKey: 'inventory.brand' },
  49. { id: 'color_name', type: 'text', labelKey: 'inventory.colorName' },
  50. { id: 'rgba', type: 'color', labelKey: 'inventory.color', pattern: '^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$' },
  51. { id: 'location_id', type: 'searchableClosed', labelKey: 'inventory.storageLocation' },
  52. { id: 'slicer_filament_name', type: 'searchable', labelKey: 'inventory.slicerFilamentName' },
  53. { id: 'slicer_filament', type: 'searchable', labelKey: 'inventory.slicerFilament' },
  54. { id: 'cost_per_kg', type: 'number', labelKey: 'inventory.costPerKg', min: 0, step: 0.01 },
  55. { id: 'note', type: 'textarea', labelKey: 'inventory.note' },
  56. { id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
  57. { id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
  58. { id: 'category', type: 'searchable', labelKey: 'inventory.category' },
  59. { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
  60. ];
  61. export interface BulkEditSpoolsModalProps {
  62. isOpen: boolean;
  63. selectedCount: number;
  64. isPending: boolean;
  65. availableLocations: Array<{ id: number; name: string }>;
  66. /** Materials seen in the user's inventory — combined with the MATERIALS constant for suggestions. */
  67. availableMaterials: string[];
  68. availableSubtypes: string[];
  69. availableBrands: string[];
  70. availableCategories: string[];
  71. availableSlicerFilaments: string[];
  72. availableSlicerFilamentNames: string[];
  73. onClose: () => void;
  74. onApply: (patch: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => void;
  75. }
  76. interface Option {
  77. value: string;
  78. label: string;
  79. }
  80. interface SearchableSelectProps {
  81. value: string;
  82. onChange: (next: string) => void;
  83. options: Option[];
  84. /** When true the user can also type a value not present in the option list. */
  85. allowCustom: boolean;
  86. placeholderKey?: string;
  87. disabled?: boolean;
  88. }
  89. /** Lightweight searchable dropdown matching the per-spool form's pattern —
  90. * text input + chevron + filtered list of buttons, click-outside closes.
  91. * Native `<select>` is intentionally avoided per the project's UI conventions. */
  92. function SearchableSelect({ value, onChange, options, allowCustom, placeholderKey, disabled }: SearchableSelectProps) {
  93. const { t } = useTranslation();
  94. const ref = useRef<HTMLDivElement>(null);
  95. const [open, setOpen] = useState(false);
  96. const [search, setSearch] = useState('');
  97. useEffect(() => {
  98. if (!open) return;
  99. const onDocClick = (e: MouseEvent) => {
  100. if (ref.current && !ref.current.contains(e.target as Node)) {
  101. setOpen(false);
  102. setSearch('');
  103. }
  104. };
  105. const onEsc = (e: KeyboardEvent) => {
  106. if (e.key === 'Escape') {
  107. setOpen(false);
  108. setSearch('');
  109. }
  110. };
  111. document.addEventListener('mousedown', onDocClick);
  112. document.addEventListener('keydown', onEsc);
  113. return () => {
  114. document.removeEventListener('mousedown', onDocClick);
  115. document.removeEventListener('keydown', onEsc);
  116. };
  117. }, [open]);
  118. const displayValue = (() => {
  119. if (open) return search;
  120. const match = options.find((o) => o.value === value);
  121. return match?.label ?? value;
  122. })();
  123. const filteredOptions = useMemo(() => {
  124. if (!open) return options;
  125. const q = search.trim().toLowerCase();
  126. if (!q) return options;
  127. return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
  128. }, [open, search, options]);
  129. const noOptionMatch = open && search.trim() && !options.some((o) => o.value.toLowerCase() === search.trim().toLowerCase());
  130. return (
  131. <div className="relative" ref={ref}>
  132. <input
  133. type="text"
  134. disabled={disabled}
  135. value={displayValue}
  136. onChange={(e) => {
  137. setSearch(e.target.value);
  138. setOpen(true);
  139. if (allowCustom) onChange(e.target.value);
  140. }}
  141. onFocus={() => {
  142. setOpen(true);
  143. setSearch('');
  144. }}
  145. placeholder={placeholderKey ? t(placeholderKey) : undefined}
  146. className="w-full px-3 py-2 pr-9 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
  147. />
  148. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
  149. {open && (
  150. <div className="absolute z-50 left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
  151. {filteredOptions.length === 0 && !allowCustom && (
  152. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  153. )}
  154. {filteredOptions.map((opt) => (
  155. <button
  156. key={opt.value}
  157. type="button"
  158. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  159. value === opt.value ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
  160. }`}
  161. onClick={() => {
  162. onChange(opt.value);
  163. setOpen(false);
  164. setSearch('');
  165. }}
  166. >
  167. {opt.label}
  168. </button>
  169. ))}
  170. {allowCustom && noOptionMatch && (
  171. <button
  172. type="button"
  173. 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"
  174. onClick={() => {
  175. onChange(search.trim());
  176. setOpen(false);
  177. setSearch('');
  178. }}
  179. >
  180. {t('inventory.bulk.useCustom', { value: search.trim() })}
  181. </button>
  182. )}
  183. </div>
  184. )}
  185. </div>
  186. );
  187. }
  188. function combineUnique(...lists: string[][]): string[] {
  189. const set = new Set<string>();
  190. for (const list of lists) for (const v of list) {
  191. const trimmed = v?.trim();
  192. if (trimmed) set.add(trimmed);
  193. }
  194. return Array.from(set).sort((a, b) => a.localeCompare(b));
  195. }
  196. export function BulkEditSpoolsModal({
  197. isOpen, selectedCount, isPending,
  198. availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
  199. availableSlicerFilaments, availableSlicerFilamentNames,
  200. onClose, onApply,
  201. }: BulkEditSpoolsModalProps) {
  202. const { t } = useTranslation();
  203. // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
  204. // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
  205. const { data: cloudPresets = [] } = useQuery({
  206. queryKey: ['bulk-edit-cloud-presets'],
  207. enabled: isOpen,
  208. staleTime: 5 * 60 * 1000,
  209. queryFn: async () => {
  210. const out: Awaited<ReturnType<typeof api.getFilamentPresets>> = [];
  211. try {
  212. const status = await api.getCloudStatus();
  213. if (status.is_authenticated) {
  214. const bambu = await api.getFilamentPresets();
  215. out.push(...bambu);
  216. }
  217. } catch {/* cloud offline → empty */}
  218. try {
  219. const orca = await api.orcaCloudStatus();
  220. if (orca.connected) {
  221. const list = await api.orcaCloudListProfiles();
  222. out.push(...(list.filament as unknown as typeof out));
  223. }
  224. } catch {/* orca offline → empty */}
  225. return out;
  226. },
  227. });
  228. const { data: localPresetsResp } = useQuery({
  229. queryKey: ['bulk-edit-local-presets'],
  230. enabled: isOpen,
  231. staleTime: 5 * 60 * 1000,
  232. queryFn: api.getLocalPresets,
  233. });
  234. const { data: builtinFilaments = [] } = useQuery({
  235. queryKey: ['builtin-filaments'],
  236. enabled: isOpen,
  237. staleTime: 5 * 60 * 1000,
  238. queryFn: api.getBuiltinFilaments,
  239. });
  240. const filamentOptions = useMemo(
  241. () => buildFilamentOptions(cloudPresets, new Set(), localPresetsResp?.filament ?? [], builtinFilaments),
  242. [cloudPresets, localPresetsResp, builtinFilaments],
  243. );
  244. // Per-field state: each entry is either undefined (leave unchanged) or
  245. // the new value. Clearing fields in bulk is intentionally NOT supported
  246. // (user decision on #1795): leave clearing to the per-spool editor so
  247. // an accidental "blank everything" isn't a single mis-click away.
  248. const [values, setValues] = useState<Record<string, string>>({});
  249. // Merge inventory-seen values with the canonical option lists so users
  250. // see the same dropdown choices the per-spool editor surfaces.
  251. const materialOptions: Option[] = useMemo(
  252. () => combineUnique(MATERIALS, availableMaterials).map((m) => ({ value: m, label: m })),
  253. [availableMaterials],
  254. );
  255. const subtypeOptions: Option[] = useMemo(
  256. () => combineUnique(KNOWN_VARIANTS, availableSubtypes).map((m) => ({ value: m, label: m })),
  257. [availableSubtypes],
  258. );
  259. const brandOptions: Option[] = useMemo(
  260. () => combineUnique(DEFAULT_BRANDS, availableBrands).map((m) => ({ value: m, label: m })),
  261. [availableBrands],
  262. );
  263. const categoryOptions: Option[] = useMemo(
  264. () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
  265. [availableCategories],
  266. );
  267. const slicerFilamentOptions: Option[] = useMemo(() => {
  268. // value = preset code (what goes into spool.slicer_filament),
  269. // label = display name so the user can find it by name.
  270. const fromPresets = filamentOptions.map((p) => ({ value: p.code, label: p.displayName }));
  271. const fromInventory = availableSlicerFilaments
  272. .filter((code) => !fromPresets.some((p) => p.value === code))
  273. .map((code) => ({ value: code, label: code }));
  274. return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
  275. }, [filamentOptions, availableSlicerFilaments]);
  276. const slicerFilamentNameOptions: Option[] = useMemo(() => {
  277. const fromPresets = filamentOptions.map((p) => ({ value: p.displayName, label: p.displayName }));
  278. const fromInventory = availableSlicerFilamentNames
  279. .filter((name) => !fromPresets.some((p) => p.value === name))
  280. .map((name) => ({ value: name, label: name }));
  281. return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
  282. }, [filamentOptions, availableSlicerFilamentNames]);
  283. const locationOptions: Option[] = useMemo(
  284. () => availableLocations.map((l) => ({ value: String(l.id), label: l.name })),
  285. [availableLocations],
  286. );
  287. if (!isOpen) return null;
  288. const setField = (id: EditableField, value: string) => {
  289. setValues((prev) => ({ ...prev, [id]: value }));
  290. };
  291. const unsetField = (id: EditableField) => {
  292. setValues((prev) => {
  293. const next = { ...prev };
  294. delete next[id];
  295. return next;
  296. });
  297. };
  298. const buildPatch = (): Record<string, string | number> => {
  299. const patch: Record<string, string | number> = {};
  300. for (const f of FIELDS) {
  301. const raw = values[f.id];
  302. if (raw === undefined) continue;
  303. const trimmed = typeof raw === 'string' ? raw.trim() : raw;
  304. if (trimmed === '' || trimmed === null) continue;
  305. if (f.type === 'number') {
  306. const n = Number(trimmed);
  307. if (Number.isFinite(n)) patch[f.id] = n;
  308. } else if (f.id === 'location_id') {
  309. const n = Number(trimmed);
  310. if (Number.isFinite(n) && n > 0) patch[f.id] = n;
  311. } else if (f.id === 'rgba') {
  312. const hex = String(trimmed).replace(/^#/, '');
  313. const normalized = hex.length === 6 ? `${hex}FF` : hex;
  314. if (/^[0-9A-Fa-f]{8}$/.test(normalized)) patch[f.id] = normalized.toUpperCase();
  315. } else {
  316. patch[f.id] = String(trimmed);
  317. }
  318. }
  319. return patch;
  320. };
  321. const patch = buildPatch();
  322. const hasChanges = Object.keys(patch).length > 0;
  323. // Block Apply when any ticked-and-non-empty field has invalid input that
  324. // would be silently dropped from the patch — e.g. a malformed rgba hex.
  325. // Without this guard the user clicks Apply, the field is dropped, and the
  326. // success toast still fires for the OTHER fields.
  327. const hasDroppedTickedField = FIELDS.some((f) => {
  328. const raw = values[f.id];
  329. if (raw === undefined) return false;
  330. if (raw.trim() === '') return false;
  331. return patch[f.id] === undefined;
  332. });
  333. const optionsFor = (id: EditableField): Option[] => {
  334. if (id === 'material') return materialOptions;
  335. if (id === 'subtype') return subtypeOptions;
  336. if (id === 'brand') return brandOptions;
  337. if (id === 'category') return categoryOptions;
  338. if (id === 'slicer_filament') return slicerFilamentOptions;
  339. if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
  340. if (id === 'location_id') return locationOptions;
  341. return [];
  342. };
  343. const renderInput = (f: FieldSpec) => {
  344. const value = values[f.id] ?? '';
  345. if (f.type === 'searchable' || f.type === 'searchableClosed') {
  346. return (
  347. <SearchableSelect
  348. value={value}
  349. onChange={(next) => {
  350. if (next === '') unsetField(f.id);
  351. else setField(f.id, next);
  352. }}
  353. options={optionsFor(f.id)}
  354. allowCustom={f.type === 'searchable'}
  355. disabled={isPending}
  356. />
  357. );
  358. }
  359. if (f.type === 'textarea') {
  360. return (
  361. <textarea
  362. disabled={isPending}
  363. value={value}
  364. onChange={(e) => setField(f.id, e.target.value)}
  365. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none resize-none min-h-[60px]"
  366. />
  367. );
  368. }
  369. if (f.type === 'color') {
  370. const hexCandidate = value.trim().replace(/^#/, '');
  371. const normalized = hexCandidate.length === 6 ? `${hexCandidate}FF` : hexCandidate;
  372. const isInvalid = value.trim() !== '' && !/^[0-9A-Fa-f]{8}$/.test(normalized);
  373. return (
  374. <div>
  375. <div className="flex items-center gap-2">
  376. <input
  377. type="color"
  378. disabled={isPending}
  379. value={`#${(value || '808080').replace(/^#/, '').slice(0, 6)}`}
  380. onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
  381. className="h-9 w-12 rounded cursor-pointer"
  382. />
  383. <input
  384. type="text"
  385. disabled={isPending}
  386. value={value}
  387. onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
  388. placeholder="RRGGBB or RRGGBBAA"
  389. className={`flex-1 px-3 py-2 bg-bambu-dark border rounded-lg text-white placeholder-bambu-gray/50 focus:outline-none ${isInvalid ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'}`}
  390. pattern={f.pattern}
  391. />
  392. </div>
  393. {isInvalid && (
  394. <p className="mt-1 text-xs text-red-600 dark:text-red-400">{t('inventory.bulk.invalidHex')}</p>
  395. )}
  396. </div>
  397. );
  398. }
  399. return (
  400. <input
  401. type={f.type === 'number' ? 'number' : 'text'}
  402. disabled={isPending}
  403. value={value}
  404. onChange={(e) => setField(f.id, e.target.value)}
  405. min={f.min}
  406. max={f.max}
  407. step={f.step}
  408. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
  409. />
  410. );
  411. };
  412. return (
  413. <div
  414. className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
  415. onClick={isPending ? undefined : onClose}
  416. >
  417. <div
  418. className="w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg max-h-[90vh] flex flex-col"
  419. onClick={(e) => e.stopPropagation()}
  420. >
  421. <div className="flex items-center justify-between p-5 border-b border-bambu-dark-tertiary">
  422. <div>
  423. <h2 className="text-lg font-semibold text-white">
  424. {t('inventory.bulk.editTitle')}
  425. </h2>
  426. <p className="text-sm text-bambu-gray mt-0.5">
  427. {t('inventory.bulk.editSubtitle', { count: selectedCount })}
  428. </p>
  429. </div>
  430. <button
  431. onClick={onClose}
  432. disabled={isPending}
  433. className="p-1 text-bambu-gray hover:text-white transition-colors"
  434. aria-label={t('common.close')}
  435. >
  436. <X className="w-5 h-5" />
  437. </button>
  438. </div>
  439. <p className="px-5 pt-4 text-xs text-bambu-gray">
  440. {t('inventory.bulk.editHint')}
  441. </p>
  442. <div className="flex-1 overflow-y-auto p-5 space-y-3">
  443. {FIELDS.map((f) => {
  444. const enabled = values[f.id] !== undefined;
  445. return (
  446. <div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>
  447. <div className="pt-2">
  448. <input
  449. type="checkbox"
  450. className="h-4 w-4 cursor-pointer"
  451. checked={enabled}
  452. onChange={(e) => {
  453. if (e.target.checked) setField(f.id, '');
  454. else unsetField(f.id);
  455. }}
  456. aria-label={t('inventory.bulk.toggleField')}
  457. />
  458. </div>
  459. <div className="flex-1">
  460. <label className="block text-sm text-bambu-gray mb-1">
  461. {t(f.labelKey)}
  462. </label>
  463. {renderInput(f)}
  464. </div>
  465. </div>
  466. );
  467. })}
  468. </div>
  469. <div className="flex items-center gap-3 p-5 border-t border-bambu-dark-tertiary">
  470. <span className="text-xs text-bambu-gray">
  471. {t('inventory.bulk.changeCount', { count: Object.keys(patch).length })}
  472. </span>
  473. <div className="ml-auto flex gap-2">
  474. <Button variant="secondary" onClick={onClose} disabled={isPending}>
  475. {t('common.cancel')}
  476. </Button>
  477. <Button
  478. onClick={() => onApply(patch as Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>)}
  479. disabled={!hasChanges || isPending || hasDroppedTickedField}
  480. >
  481. {isPending ? (
  482. <>
  483. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  484. {t('inventory.bulk.applyPending')}
  485. </>
  486. ) : (
  487. t('inventory.bulk.applyButton', { count: selectedCount })
  488. )}
  489. </Button>
  490. </div>
  491. </div>
  492. </div>
  493. </div>
  494. );
  495. }