LabelTemplatePickerModal.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
  4. import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
  5. import { Button } from './Button';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
  8. import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
  9. /** Subset of InventorySpool the modal needs for checkbox rendering. */
  10. type SpoolForLabel = Pick<
  11. InventorySpool,
  12. 'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'color_name_is_synthesized' | 'rgba'
  13. >;
  14. interface LabelTemplatePickerModalProps {
  15. isOpen: boolean;
  16. onClose: () => void;
  17. /** All spools the modal can choose from. Typically the page's current
  18. * filter result so the modal stays consistent with what the user sees. */
  19. availableSpools: SpoolForLabel[];
  20. /** IDs to pre-check when the modal opens. Per-card icon passes a single ID;
  21. * the bulk header button passes every visible ID so the user lands in
  22. * "all checked" and refines downward. */
  23. initialSelectedIds: number[];
  24. spoolmanMode: boolean;
  25. }
  26. interface TemplateOption {
  27. value: SpoolLabelTemplate;
  28. i18nKey: string;
  29. fallbackLabel: string;
  30. fallbackHint: string;
  31. }
  32. const TEMPLATE_OPTIONS: TemplateOption[] = [
  33. {
  34. value: 'ams_holder_74x33',
  35. i18nKey: 'amsHolderSmall',
  36. fallbackLabel: 'AMS holder — small (74 × 33 mm)',
  37. fallbackHint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
  38. },
  39. {
  40. value: 'ams_holder_75x55',
  41. i18nKey: 'amsHolderLarge',
  42. fallbackLabel: 'AMS holder — large (75 × 55 mm)',
  43. fallbackHint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
  44. },
  45. {
  46. value: 'box_40x30',
  47. i18nKey: 'box40x30',
  48. fallbackLabel: 'Box label (40 × 30 mm)',
  49. fallbackHint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
  50. },
  51. {
  52. value: 'box_62x29',
  53. i18nKey: 'box',
  54. fallbackLabel: 'Box label (62 × 29 mm)',
  55. fallbackHint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
  56. },
  57. {
  58. value: 'avery_l7160',
  59. i18nKey: 'averyL7160',
  60. fallbackLabel: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
  61. fallbackHint: 'EU sheet stock; 21 labels per A4 page.',
  62. },
  63. {
  64. value: 'avery_5160',
  65. i18nKey: 'avery5160',
  66. fallbackLabel: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
  67. fallbackHint: 'US sheet stock; 30 labels per Letter page.',
  68. },
  69. ];
  70. const SHEET_CAPACITIES: Partial<Record<SpoolLabelTemplate, number>> = {
  71. avery_l7160: 21,
  72. avery_5160: 30,
  73. };
  74. const MAX_SHEET_CAPACITY = Math.max(...Object.values(SHEET_CAPACITIES));
  75. function openBlobInNewTab(blob: Blob): void {
  76. const url = window.URL.createObjectURL(blob);
  77. // Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
  78. // forces window.open to return `null` even on success, which made the
  79. // `if (!win)` popup-block fallback below fire on EVERY click — so the blob
  80. // tab opened (downloading a random-named PDF on systems without an inline
  81. // viewer) AND the `<a download>` fallback fired (downloading a second copy
  82. // named bambuddy-labels.pdf). Two identical PDFs per click — issue #1628.
  83. // The blob is same-origin, the destination is a passive PDF tab with no
  84. // script context, and `noreferrer` is a no-op for blob URLs, so dropping
  85. // these flags has no security impact.
  86. const win = window.open(url, '_blank');
  87. if (!win) {
  88. const a = document.createElement('a');
  89. a.href = url;
  90. a.download = 'bambuddy-labels.pdf';
  91. document.body.appendChild(a);
  92. a.click();
  93. document.body.removeChild(a);
  94. }
  95. setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
  96. }
  97. // Thin wrapper over `getSwatchStyle` from utils/colors so the modal's render
  98. // sites keep their existing call shape. Transparent (alpha=00) spools now
  99. // render as a checkerboard pattern instead of collapsing to solid black
  100. // (#1545).
  101. function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
  102. return getSwatchStyle(rgba);
  103. }
  104. function labelColorName(s: SpoolForLabel): string | null {
  105. return resolveSpoolColorName(s.color_name, s.rgba, s.color_name_is_synthesized);
  106. }
  107. function spoolDisplayName(s: SpoolForLabel): string {
  108. // Resolved, not stored: most Bambu spools arrive with no colour name on the
  109. // tag, and picking a label template for "PLA Silk" tells you nothing about
  110. // which of six red spools you are looking at (#3090).
  111. const head = labelColorName(s) ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
  112. const brand = s.brand ? ` · ${s.brand}` : '';
  113. return `${head}${brand}`;
  114. }
  115. /** Build a lowercased haystack that the search input matches against. */
  116. function searchableText(s: SpoolForLabel): string {
  117. return [labelColorName(s), s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
  118. .filter(Boolean)
  119. .join(' ')
  120. .toLowerCase();
  121. }
  122. type SortMode = 'id' | 'color';
  123. /** Sort key for the "by colour" mode (#1410).
  124. *
  125. * Returns a 2-tuple so JS array compare does the right thing without us having
  126. * to spell out a comparator: ``[bucket, position]``. Chromatic colours
  127. * (saturation above the threshold) go in bucket 0 ordered by HSL hue, so the
  128. * sheet reads as a continuous rainbow. Achromatic colours (white / grey /
  129. * black, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the
  130. * neutrals trail at the end of the rainbow going dark → light. Multi-colour
  131. * spools sort on their primary ``rgba``; their ``extra_colors`` stripe is
  132. * still rendered on the label itself but doesn't drive the sort.
  133. */
  134. function colorSortKey(rgba: string | null | undefined): [number, number] {
  135. if (!rgba) return [1, 0]; // unknown colour — bucket with the neutrals at black
  136. const cleaned = rgba.replace(/^#/, '').slice(0, 6);
  137. if (cleaned.length !== 6) return [1, 0];
  138. const r = parseInt(cleaned.slice(0, 2), 16);
  139. const g = parseInt(cleaned.slice(2, 4), 16);
  140. const b = parseInt(cleaned.slice(4, 6), 16);
  141. if ([r, g, b].some(Number.isNaN)) return [1, 0];
  142. const rn = r / 255;
  143. const gn = g / 255;
  144. const bn = b / 255;
  145. const max = Math.max(rn, gn, bn);
  146. const min = Math.min(rn, gn, bn);
  147. const l = (max + min) / 2;
  148. const delta = max - min;
  149. // Saturation in the HSL definition. Achromatic cutoff at 0.1 is generous —
  150. // matches what feels "grey enough" to a user picking colours, without
  151. // sending dark muted colours like deep navy into the neutrals bucket.
  152. const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
  153. if (s < 0.1) return [1, l]; // neutrals: ordered black → white
  154. let h = 0;
  155. if (max === rn) h = ((gn - bn) / delta) % 6;
  156. else if (max === gn) h = (bn - rn) / delta + 2;
  157. else h = (rn - gn) / delta + 4;
  158. h = h * 60;
  159. if (h < 0) h += 360;
  160. return [0, h]; // chromatic: ordered by hue 0..360
  161. }
  162. export function LabelTemplatePickerModal({
  163. isOpen,
  164. onClose,
  165. availableSpools,
  166. initialSelectedIds,
  167. spoolmanMode,
  168. }: LabelTemplatePickerModalProps) {
  169. const { t } = useTranslation();
  170. // The spool filter below resolves colour names through the catalog; its
  171. // memo has to recompute when the catalog finishes loading (#3090).
  172. const colorCatalogVersion = useColorCatalogVersion();
  173. const { showToast } = useToast();
  174. const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
  175. const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
  176. const [search, setSearch] = useState('');
  177. const [materialFilter, setMaterialFilter] = useState<string>('');
  178. const [sortMode, setSortMode] = useState<SortMode>('id');
  179. const [monochrome, setMonochrome] = useState(false);
  180. const [startingPositionInput, setStartingPositionInput] = useState('1');
  181. // Sync from caller and reset transient state on open. Intentionally not
  182. // reactive to props while open — once the user starts editing we don't want
  183. // a parent re-render to clobber their selection / filter / search.
  184. useEffect(() => {
  185. if (isOpen) {
  186. const allowed = new Set(availableSpools.map((s) => s.id));
  187. setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
  188. setSearch('');
  189. setMaterialFilter('');
  190. setSortMode('id');
  191. setMonochrome(false);
  192. setStartingPositionInput('1');
  193. setPending(null);
  194. }
  195. // eslint-disable-next-line react-hooks/exhaustive-deps
  196. }, [isOpen]);
  197. const sortedSpools = useMemo(() => {
  198. const copy = [...availableSpools];
  199. if (sortMode === 'color') {
  200. copy.sort((a, b) => {
  201. const ka = colorSortKey(a.rgba);
  202. const kb = colorSortKey(b.rgba);
  203. if (ka[0] !== kb[0]) return ka[0] - kb[0];
  204. if (ka[1] !== kb[1]) return ka[1] - kb[1];
  205. // Stable tiebreaker on ID so identical colours print in a deterministic
  206. // order across renders.
  207. return a.id - b.id;
  208. });
  209. return copy;
  210. }
  211. copy.sort((a, b) => a.id - b.id);
  212. return copy;
  213. }, [availableSpools, sortMode]);
  214. // Material chips are derived from the *full* available set so they stay
  215. // stable when search/material filter narrows the visible list.
  216. const materials = useMemo(() => {
  217. const set = new Set<string>();
  218. for (const s of sortedSpools) {
  219. if (s.material) set.add(s.material.toUpperCase());
  220. }
  221. return [...set].sort();
  222. }, [sortedSpools]);
  223. const visibleSpools = useMemo(() => {
  224. // Named so this memo depends on it: searchableText resolves colour names
  225. // through the catalog, which resolveSpoolColorName reads from module state
  226. // the linter cannot follow. Without it a query typed before the catalog
  227. // loads keeps its empty result (#3090).
  228. void colorCatalogVersion;
  229. const q = search.trim().toLowerCase();
  230. return sortedSpools.filter((s) => {
  231. if (materialFilter && (s.material || '').toUpperCase() !== materialFilter) return false;
  232. if (q && !searchableText(s).includes(q)) return false;
  233. return true;
  234. });
  235. }, [sortedSpools, search, materialFilter, colorCatalogVersion]);
  236. const allVisibleChecked =
  237. visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));
  238. if (!isOpen) return null;
  239. const selectedCount = selectedIds.size;
  240. const noSelection = selectedCount === 0;
  241. const startingPosition = Number(startingPositionInput);
  242. const startingPositionIsValid =
  243. Number.isInteger(startingPosition) &&
  244. startingPosition >= 1 &&
  245. startingPosition <= MAX_SHEET_CAPACITY;
  246. function toggleOne(id: number) {
  247. setSelectedIds((prev) => {
  248. const next = new Set(prev);
  249. if (next.has(id)) next.delete(id);
  250. else next.add(id);
  251. return next;
  252. });
  253. }
  254. function selectAllVisible() {
  255. setSelectedIds((prev) => {
  256. const next = new Set(prev);
  257. for (const s of visibleSpools) next.add(s.id);
  258. return next;
  259. });
  260. }
  261. function deselectVisible() {
  262. setSelectedIds((prev) => {
  263. const next = new Set(prev);
  264. for (const s of visibleSpools) next.delete(s.id);
  265. return next;
  266. });
  267. }
  268. function clearAll() {
  269. setSelectedIds(new Set());
  270. }
  271. async function handlePick(template: SpoolLabelTemplate) {
  272. if (noSelection || pending) return;
  273. const sheetCapacity = SHEET_CAPACITIES[template];
  274. if (
  275. sheetCapacity !== undefined &&
  276. (!startingPositionIsValid || startingPosition > sheetCapacity)
  277. ) {
  278. showToast(
  279. t(
  280. 'inventory.labels.startingPositionRangeError',
  281. 'Starting position must be between 1 and {{capacity}} for this sheet.',
  282. { capacity: sheetCapacity },
  283. ),
  284. 'error',
  285. );
  286. return;
  287. }
  288. // Order matters: the backend (labels.py) prints labels in the same order
  289. // we send IDs. Use the sorted list so a "by colour" sort flows through to
  290. // the PDF instead of being clobbered by an ascending-ID re-sort.
  291. const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
  292. setPending(template);
  293. try {
  294. const blob = spoolmanMode
  295. ? await api.printSpoolmanSpoolLabels({
  296. spool_ids: ids,
  297. template,
  298. monochrome,
  299. starting_position: sheetCapacity === undefined ? 1 : startingPosition,
  300. })
  301. : await api.printSpoolLabels({
  302. spool_ids: ids,
  303. template,
  304. monochrome,
  305. starting_position: sheetCapacity === undefined ? 1 : startingPosition,
  306. });
  307. openBlobInNewTab(blob);
  308. onClose();
  309. } catch (err) {
  310. const msg = err instanceof Error ? err.message : String(err);
  311. showToast(
  312. t('inventory.labels.error', 'Could not generate labels: {{msg}}', { msg }),
  313. 'error',
  314. );
  315. } finally {
  316. setPending(null);
  317. }
  318. }
  319. return (
  320. <div className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto">
  321. <div
  322. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  323. onClick={onClose}
  324. />
  325. <div
  326. data-testid="label-template-picker-panel"
  327. className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-clip flex flex-col my-auto"
  328. >
  329. {/* Header */}
  330. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  331. <div className="flex items-center gap-2">
  332. <Printer className="w-5 h-5 text-bambu-green" />
  333. <h2 className="text-lg font-semibold text-white">
  334. {t('inventory.labels.title', 'Print spool labels')}
  335. </h2>
  336. {selectedCount > 0 && (
  337. <span className="text-sm text-bambu-gray">
  338. ({t('inventory.labels.selectedCount', '{{count}} selected', { count: selectedCount })})
  339. </span>
  340. )}
  341. </div>
  342. <button
  343. onClick={onClose}
  344. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  345. aria-label={t('common.close', 'Close')}
  346. >
  347. <X className="w-5 h-5" />
  348. </button>
  349. </div>
  350. {/* Search + material chips */}
  351. <div className="p-4 space-y-2 border-b border-bambu-dark-tertiary">
  352. <div className="relative">
  353. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  354. <input
  355. type="search"
  356. value={search}
  357. onChange={(e) => setSearch(e.target.value)}
  358. placeholder={t('inventory.labels.searchPlaceholder', 'Search name, brand, or #ID')}
  359. 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 focus:outline-none focus:border-bambu-green"
  360. />
  361. </div>
  362. {materials.length > 1 && (
  363. <div className="flex flex-wrap items-center gap-1.5">
  364. <span className="text-xs text-bambu-gray mr-1">
  365. {t('inventory.labels.filterByMaterial', 'Material:')}
  366. </span>
  367. <button
  368. type="button"
  369. onClick={() => setMaterialFilter('')}
  370. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  371. materialFilter === ''
  372. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  373. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  374. }`}
  375. >
  376. {t('inventory.labels.allMaterials', 'All')}
  377. </button>
  378. {materials.map((m) => (
  379. <button
  380. key={m}
  381. type="button"
  382. onClick={() => setMaterialFilter(m)}
  383. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  384. materialFilter === m
  385. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  386. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  387. }`}
  388. >
  389. {m}
  390. </button>
  391. ))}
  392. </div>
  393. )}
  394. <div className="flex flex-wrap items-center gap-1.5">
  395. <span className="text-xs text-bambu-gray mr-1">
  396. {t('inventory.labels.sortBy.label')}
  397. </span>
  398. <button
  399. type="button"
  400. onClick={() => setSortMode('id')}
  401. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  402. sortMode === 'id'
  403. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  404. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  405. }`}
  406. >
  407. {t('inventory.labels.sortBy.id')}
  408. </button>
  409. <button
  410. type="button"
  411. onClick={() => setSortMode('color')}
  412. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  413. sortMode === 'color'
  414. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  415. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  416. }`}
  417. >
  418. {t('inventory.labels.sortBy.color')}
  419. </button>
  420. </div>
  421. </div>
  422. {/* Action bar */}
  423. <div className="px-4 pt-3 pb-2 flex items-center justify-between gap-3 flex-wrap">
  424. <span className="text-sm text-bambu-gray">
  425. {t('inventory.labels.pickSpools', 'Pick which spools to print labels for:')}
  426. </span>
  427. <div className="flex items-center gap-3 text-xs">
  428. <button
  429. type="button"
  430. onClick={allVisibleChecked ? deselectVisible : selectAllVisible}
  431. disabled={visibleSpools.length === 0}
  432. className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
  433. >
  434. {allVisibleChecked
  435. ? t('inventory.labels.deselectVisible', 'Deselect visible')
  436. : t('inventory.labels.selectVisible', 'Select all visible ({{count}})', {
  437. count: visibleSpools.length,
  438. })}
  439. </button>
  440. <button
  441. type="button"
  442. onClick={clearAll}
  443. disabled={selectedCount === 0}
  444. className="text-bambu-gray hover:text-white hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
  445. >
  446. {t('inventory.labels.clearAll', 'Clear all')}
  447. </button>
  448. </div>
  449. </div>
  450. {/* Spool list */}
  451. <div className="flex-1 overflow-y-auto px-2 pb-2 min-h-0">
  452. {visibleSpools.length === 0 ? (
  453. <div className="text-center text-sm text-bambu-gray py-6">
  454. {sortedSpools.length === 0
  455. ? t('inventory.labels.noSpoolsToShow', 'No spools to show. Adjust your filter and try again.')
  456. : t('inventory.labels.noMatches', 'No spools match the current search or filter.')}
  457. </div>
  458. ) : (
  459. <ul className="space-y-0.5">
  460. {visibleSpools.map((s) => {
  461. const checked = selectedIds.has(s.id);
  462. return (
  463. <li key={s.id}>
  464. <label className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary/50 cursor-pointer">
  465. {checked ? (
  466. <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
  467. ) : (
  468. <Square className="w-4 h-4 text-bambu-gray shrink-0" />
  469. )}
  470. <input
  471. type="checkbox"
  472. checked={checked}
  473. onChange={() => toggleOne(s.id)}
  474. className="sr-only"
  475. />
  476. <span
  477. className="w-4 h-4 rounded border border-black/20 shrink-0"
  478. style={swatchStyle(s.rgba)}
  479. />
  480. <span className="flex-1 min-w-0 truncate text-sm text-white">
  481. {spoolDisplayName(s)}
  482. </span>
  483. <span className="text-xs font-mono text-bambu-gray shrink-0">
  484. #{s.id}
  485. </span>
  486. </label>
  487. </li>
  488. );
  489. })}
  490. </ul>
  491. )}
  492. </div>
  493. {/* Print options */}
  494. <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary space-y-2">
  495. <label className="inline-flex items-center gap-2 cursor-pointer select-none">
  496. {monochrome ? (
  497. <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
  498. ) : (
  499. <Square className="w-4 h-4 text-bambu-gray shrink-0" />
  500. )}
  501. <input
  502. type="checkbox"
  503. checked={monochrome}
  504. onChange={(e) => setMonochrome(e.target.checked)}
  505. className="sr-only"
  506. />
  507. <span className="text-sm text-white">
  508. {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
  509. </span>
  510. <span className="text-xs text-bambu-gray">
  511. {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
  512. </span>
  513. </label>
  514. <div className="flex items-start gap-3">
  515. <label
  516. htmlFor="label-starting-position"
  517. className="text-sm text-white whitespace-nowrap pt-1.5"
  518. >
  519. {t('inventory.labels.startingPosition', 'Starting label position')}
  520. </label>
  521. <input
  522. id="label-starting-position"
  523. data-testid="label-starting-position"
  524. type="number"
  525. min={1}
  526. max={MAX_SHEET_CAPACITY}
  527. step={1}
  528. value={startingPositionInput}
  529. onChange={(event) => setStartingPositionInput(event.target.value)}
  530. aria-describedby="label-starting-position-help"
  531. className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
  532. />
  533. <div id="label-starting-position-help" className="text-xs text-bambu-gray pt-1.5">
  534. <div>
  535. {t(
  536. 'inventory.labels.startingPositionRange',
  537. 'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
  538. )}
  539. </div>
  540. <div
  541. data-testid="label-starting-position-status"
  542. className={startingPositionIsValid ? '' : 'text-red-400'}
  543. >
  544. {!startingPositionIsValid
  545. ? t(
  546. 'inventory.labels.startingPositionInvalid',
  547. 'Enter a whole number from 1 to {{capacity}}.',
  548. { capacity: MAX_SHEET_CAPACITY },
  549. )
  550. : startingPosition === 1
  551. ? t('inventory.labels.startingPositionFirst', 'Printing starts at position 1.')
  552. : t(
  553. 'inventory.labels.startingPositionSkipped',
  554. 'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
  555. { lastPosition: startingPosition - 1 },
  556. )}
  557. </div>
  558. </div>
  559. </div>
  560. </div>
  561. {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
  562. inside max-h-[90vh] even when browser chrome eats into the viewport
  563. (#1230). Stacked single column on mobile widths. */}
  564. <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
  565. {TEMPLATE_OPTIONS.map((opt) => {
  566. const isPending = pending === opt.value;
  567. const sheetCapacity = SHEET_CAPACITIES[opt.value];
  568. const startingPositionExceedsSheet =
  569. sheetCapacity !== undefined &&
  570. (!startingPositionIsValid || startingPosition > sheetCapacity);
  571. const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
  572. const hint = startingPositionExceedsSheet
  573. ? t(
  574. 'inventory.labels.startingPositionRangeError',
  575. 'Starting position must be between 1 and {{capacity}} for this sheet.',
  576. { capacity: sheetCapacity },
  577. )
  578. : t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
  579. return (
  580. <button
  581. key={opt.value}
  582. data-testid={`print-labels-${opt.value}`}
  583. disabled={noSelection || pending !== null || startingPositionExceedsSheet}
  584. onClick={() => handlePick(opt.value)}
  585. title={`${label} — ${hint}`}
  586. className="w-full text-left p-2.5 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"
  587. >
  588. <div className="flex-1 min-w-0">
  589. <div className="font-medium text-white text-sm truncate">{label}</div>
  590. <div className="text-xs text-bambu-gray mt-0.5 truncate">{hint}</div>
  591. </div>
  592. {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green shrink-0" />}
  593. </button>
  594. );
  595. })}
  596. </div>
  597. <div className="flex justify-end gap-2 px-5 py-2 border-t border-bambu-dark-tertiary">
  598. <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
  599. {t('common.cancel', 'Cancel')}
  600. </Button>
  601. </div>
  602. </div>
  603. </div>
  604. );
  605. }