BulkEditSpoolsModal.tsx 21 KB

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