AssignToAmsModal.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  1. import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { X, Loader2, Check, CheckCircle, XCircle, Layers } from 'lucide-react';
  5. import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
  6. import { ConfirmModal } from '../ConfirmModal';
  7. import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
  8. import type { AmsThresholds } from './AmsUnitCard';
  9. import { getFillBarColor } from '../../utils/amsHelpers';
  10. import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
  11. import { spoolSwatchStyle } from './spoolPaint';
  12. function getAmsName(id: number): string {
  13. if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
  14. if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
  15. return `AMS ${id}`;
  16. }
  17. function isTrayEmpty(tray: AMSTray): boolean {
  18. return !tray.tray_type || tray.tray_type === '';
  19. }
  20. function trayColorToCSS(color: string | null): string {
  21. if (!color) return '#808080';
  22. return `#${color.slice(0, 6)}`;
  23. }
  24. const FOCUSABLE_SELECTOR = [
  25. 'a[href]',
  26. 'button:not([disabled])',
  27. 'input:not([disabled]):not([type="hidden"])',
  28. 'select:not([disabled])',
  29. 'textarea:not([disabled])',
  30. '[tabindex]:not([tabindex="-1"])',
  31. ].join(',');
  32. function getFocusableElements(container: HTMLElement): HTMLElement[] {
  33. return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
  34. .filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true');
  35. }
  36. // --- Material/profile mismatch helpers (pure functions, no component state) ---
  37. const normalizeValue = (value: string | undefined | null) =>
  38. (value ?? '').trim().toUpperCase();
  39. function checkMaterialMatch(
  40. spoolMaterial: string | undefined | null,
  41. trayMaterial: string | undefined | null
  42. ): 'exact' | 'partial' | 'none' {
  43. const normalizedSpool = normalizeValue(spoolMaterial);
  44. const normalizedTray = normalizeValue(trayMaterial);
  45. if (!normalizedSpool || !normalizedTray) return 'none';
  46. if (normalizedSpool === normalizedTray) return 'exact';
  47. if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
  48. return 'partial';
  49. }
  50. return 'none';
  51. }
  52. function checkProfileMatch(
  53. spoolProfile: string | undefined | null,
  54. trayProfile: string | undefined | null
  55. ): boolean {
  56. const normalizedSpoolProfile = normalizeValue(spoolProfile);
  57. const normalizedTrayProfile = normalizeValue(trayProfile);
  58. if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
  59. return normalizedSpoolProfile === normalizedTrayProfile;
  60. }
  61. interface AssignToAmsModalProps {
  62. isOpen: boolean;
  63. onClose: () => void;
  64. spool: InventorySpool;
  65. printerId: number | null;
  66. variant?: 'dialog' | 'kiosk';
  67. spoolmanMode?: boolean;
  68. }
  69. export function AssignToAmsModal({
  70. isOpen,
  71. onClose,
  72. spool,
  73. printerId,
  74. variant = 'dialog',
  75. spoolmanMode = false,
  76. }: AssignToAmsModalProps) {
  77. const { t } = useTranslation();
  78. const queryClient = useQueryClient();
  79. const dialogRef = useRef<HTMLDivElement>(null);
  80. const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(printerId);
  81. const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
  82. const [statusMessage, setStatusMessage] = useState<string | null>(null);
  83. const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
  84. const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
  85. // Profile-only mismatches no longer trigger the popup — the backend
  86. // pushes the spool's slicer profile to the AMS slot on every assign
  87. // anyway, so the warning was friction without benefit (#1552). Material
  88. // mismatch still warns because firmware can refuse a print when type
  89. // doesn't match.
  90. const [mismatchDetails, setMismatchDetails] = useState<{
  91. type: 'material' | 'partial' | 'material_profile' | 'partial_profile';
  92. spoolMaterial: string;
  93. trayMaterial: string;
  94. spoolProfile?: string;
  95. trayProfile?: string;
  96. location: string;
  97. } | null>(null);
  98. const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);
  99. useEffect(() => {
  100. if (isOpen) {
  101. setSelectedPrinterId(printerId);
  102. setSelectedSlot(null);
  103. setStatusMessage(null);
  104. setStatusType(null);
  105. setShowMismatchConfirm(false);
  106. setMismatchDetails(null);
  107. setPendingSlot(null);
  108. }
  109. }, [isOpen, printerId, spool.id]);
  110. // Printer selection and visual presentation are separate concerns: the
  111. // SpoolBuddy kiosk must stay full-screen even when no printer is online and
  112. // the picker is shown.
  113. const showPrinterPicker = printerId === null;
  114. const requiresSlotConfirmation = showPrinterPicker;
  115. const targetPrinterId = printerId ?? selectedPrinterId;
  116. const { data: selectablePrinters = [], isLoading: printersLoading } = useQuery({
  117. queryKey: ['printers'],
  118. queryFn: () => api.getPrinters(),
  119. enabled: isOpen && showPrinterPicker,
  120. });
  121. const activePrinters = useMemo(
  122. () => selectablePrinters.filter((candidate) => candidate.is_active !== false),
  123. [selectablePrinters],
  124. );
  125. useEffect(() => {
  126. if (!isOpen) return;
  127. const previousOverflow = document.body.style.overflow;
  128. const previouslyFocused = document.activeElement instanceof HTMLElement
  129. ? document.activeElement
  130. : null;
  131. document.body.style.overflow = 'hidden';
  132. const dialog = dialogRef.current;
  133. const firstFocusable = dialog ? getFocusableElements(dialog)[0] : null;
  134. (firstFocusable ?? dialog)?.focus();
  135. return () => {
  136. document.body.style.overflow = previousOverflow;
  137. if (previouslyFocused?.isConnected) previouslyFocused.focus();
  138. };
  139. }, [isOpen]);
  140. const handleKeyDown = useCallback((event: KeyboardEvent) => {
  141. // ConfirmModal is the active modal while a material warning is visible.
  142. // Let it own Escape and keyboard focus until it closes.
  143. if (showMismatchConfirm) return;
  144. if (event.key === 'Escape') {
  145. event.preventDefault();
  146. onClose();
  147. return;
  148. }
  149. if (event.key !== 'Tab') return;
  150. const dialog = dialogRef.current;
  151. if (!dialog) return;
  152. const focusable = getFocusableElements(dialog);
  153. if (focusable.length === 0) {
  154. event.preventDefault();
  155. dialog.focus();
  156. return;
  157. }
  158. const first = focusable[0];
  159. const last = focusable[focusable.length - 1];
  160. const active = document.activeElement;
  161. if (event.shiftKey && (active === first || !dialog.contains(active))) {
  162. event.preventDefault();
  163. last.focus();
  164. } else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
  165. event.preventDefault();
  166. first.focus();
  167. }
  168. }, [onClose, showMismatchConfirm]);
  169. useEffect(() => {
  170. if (!isOpen) return;
  171. document.addEventListener('keydown', handleKeyDown);
  172. return () => document.removeEventListener('keydown', handleKeyDown);
  173. }, [isOpen, handleKeyDown]);
  174. const { data: status, isLoading: statusLoading } = useQuery<PrinterStatus>({
  175. queryKey: ['printerStatus', targetPrinterId],
  176. queryFn: () => api.getPrinterStatus(targetPrinterId!),
  177. enabled: isOpen && targetPrinterId !== null,
  178. refetchInterval: 5000,
  179. });
  180. const { data: printer } = useQuery({
  181. queryKey: ['printer', targetPrinterId],
  182. queryFn: () => api.getPrinter(targetPrinterId!),
  183. enabled: isOpen && targetPrinterId !== null,
  184. });
  185. const { data: settings } = useQuery({
  186. queryKey: ['settings'],
  187. queryFn: () => api.getSettings(),
  188. enabled: isOpen,
  189. staleTime: 5 * 60 * 1000,
  190. });
  191. const { data: assignments } = useQuery({
  192. queryKey: ['spool-assignments', targetPrinterId],
  193. queryFn: () => api.getAssignments(targetPrinterId!),
  194. enabled: isOpen && targetPrinterId !== null,
  195. staleTime: 30 * 1000,
  196. });
  197. const { data: spoolmanAssignments = [] } = useQuery({
  198. queryKey: ['spoolman-slot-assignments', targetPrinterId],
  199. queryFn: () => api.getSpoolmanSlotAssignments(targetPrinterId ?? undefined),
  200. enabled: isOpen && !!spoolmanMode && targetPrinterId !== null,
  201. staleTime: 30 * 1000,
  202. });
  203. const currentAssignment = spoolmanMode
  204. ? spoolmanAssignments.find(a => a.spoolman_spool_id === spool.id)
  205. : undefined;
  206. // Build fill-level override map from inventory assignments
  207. const fillOverrides = useMemo(() => {
  208. const map: Record<string, number> = {};
  209. if (!assignments) return map;
  210. for (const a of assignments) {
  211. const sp = a.spool;
  212. if (sp && sp.label_weight > 0 && sp.weight_used != null) {
  213. const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
  214. map[`${a.ams_id}-${a.tray_id}`] = fill;
  215. }
  216. }
  217. return map;
  218. }, [assignments]);
  219. const amsThresholds: AmsThresholds | undefined = settings ? {
  220. humidityGood: Number(settings.ams_humidity_good) || 40,
  221. humidityFair: Number(settings.ams_humidity_fair) || 60,
  222. tempGood: Number(settings.ams_temp_good) || 28,
  223. tempFair: Number(settings.ams_temp_fair) || 35,
  224. } : undefined;
  225. const isConnected = status?.connected ?? false;
  226. const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
  227. const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
  228. const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
  229. const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
  230. const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
  231. const cachedAmsExtruderMap = useRef<Record<string, number>>({});
  232. useEffect(() => {
  233. cachedAmsExtruderMap.current = {};
  234. }, [targetPrinterId]);
  235. useEffect(() => {
  236. if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
  237. cachedAmsExtruderMap.current = status.ams_extruder_map;
  238. }
  239. }, [status?.ams_extruder_map]);
  240. const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
  241. ? status.ams_extruder_map
  242. : cachedAmsExtruderMap.current;
  243. const ftsInstalled = status?.fila_switch?.installed === true;
  244. const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
  245. if (!isDualNozzle) return null;
  246. const mappedExtruderId = amsExtruderMap[String(amsId)];
  247. if (mappedExtruderId !== undefined) return mappedExtruderId === 1 ? 'L' : 'R';
  248. // With a Filament Track Switch every AMS reports extruder 0xE and reaches
  249. // both nozzles through the switch, so there is no side to show. The unit-id
  250. // guess below would label them all "R" — it exists only for dual-nozzle
  251. // printers that never sent a map at all. See PrintersPage.amsSideBadge,
  252. // which shows the switch inlet in place of L/R on the printer card.
  253. if (ftsInstalled) return null;
  254. const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
  255. return normalizedId === 1 ? 'L' : 'R';
  256. }, [isDualNozzle, amsExtruderMap, ftsInstalled]);
  257. // Assign spool to AMS slot — single API call, backend handles both DB record
  258. // AND MQTT auto-configuration. When the target slot is currently empty, the
  259. // backend persists the assignment and skips the MQTT publish (firmware drops
  260. // it anyway); on_ams_change re-fires the full configuration when filament is
  261. // later inserted. The response's `pending_config` flag distinguishes that
  262. // from the immediate-apply path so we can adjust the success toast.
  263. const configureMutation = useMutation({
  264. mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
  265. if (!targetPrinterId) throw new Error('No printer selected');
  266. if (spoolmanMode) {
  267. return await api.assignSpoolmanSlot({
  268. spoolman_spool_id: spool.id,
  269. printer_id: targetPrinterId,
  270. ams_id: amsId,
  271. tray_id: trayId,
  272. });
  273. }
  274. return await api.assignSpool({
  275. spool_id: spool.id,
  276. printer_id: targetPrinterId,
  277. ams_id: amsId,
  278. tray_id: trayId,
  279. });
  280. },
  281. onSuccess: (assignment) => {
  282. setStatusType('success');
  283. // pending_config only exists on SpoolAssignment (the local-inventory path);
  284. // the Spoolman path returns InventorySpool which always implies immediate apply.
  285. const pendingConfig = assignment && 'pending_config' in assignment && assignment.pending_config;
  286. if (pendingConfig) {
  287. setStatusMessage(
  288. t(
  289. 'spoolbuddy.modal.assignPendingInsert',
  290. 'Assigned. Slot will configure when you insert the spool.',
  291. ),
  292. );
  293. } else {
  294. setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
  295. }
  296. queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
  297. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  298. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  299. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  300. queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
  301. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  302. setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
  303. },
  304. onError: (err) => {
  305. setStatusType('error');
  306. setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
  307. },
  308. });
  309. const isWaiting = configureMutation.isPending;
  310. const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
  311. if (amsId === 254 || amsId === 255) {
  312. const extTrayId = amsId === 254 ? 254 : 254 + trayId;
  313. return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
  314. }
  315. const unit = amsUnits.find(u => u.id === amsId);
  316. return unit?.tray?.find(t => t.id === trayId) || null;
  317. }, [amsUnits, vtTrays]);
  318. const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
  319. if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
  320. if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
  321. if (!isDualNozzle) return t('printers.ext', 'Ext');
  322. // Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R;
  323. // AMS 254 is retained for compatibility with older assignment records.
  324. if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
  325. return t('printers.extR', 'Ext-R');
  326. }, [t, isDualNozzle]);
  327. const doAssign = useCallback((amsId: number, trayId: number) => {
  328. setStatusType('info');
  329. setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
  330. configureMutation.mutate({ amsId, trayId });
  331. }, [configureMutation, t]);
  332. const prepareAssignment = useCallback((amsId: number, trayId: number) => {
  333. if (isWaiting) return;
  334. if (!settings?.disable_filament_warnings) {
  335. const tray = getTrayForSlot(amsId, trayId);
  336. if (tray && !isTrayEmpty(tray)) {
  337. const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';
  338. const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
  339. const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
  340. const trayProfile = tray.tray_type || '';
  341. const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
  342. if (materialMatchResult !== 'exact') {
  343. let mismatchType: 'material' | 'partial' | 'material_profile' | 'partial_profile';
  344. if (materialMatchResult === 'none' && !profileMatches) {
  345. mismatchType = 'material_profile';
  346. } else if (materialMatchResult === 'partial' && !profileMatches) {
  347. mismatchType = 'partial_profile';
  348. } else if (materialMatchResult === 'none') {
  349. mismatchType = 'material';
  350. } else {
  351. mismatchType = 'partial';
  352. }
  353. const location = getSlotLocationLabel(amsId, trayId);
  354. setPendingSlot({ amsId, trayId });
  355. setMismatchDetails({
  356. type: mismatchType,
  357. spoolMaterial: spool.material || '',
  358. trayMaterial: trayMaterial || '',
  359. spoolProfile: spoolProfile || undefined,
  360. trayProfile: trayProfile || undefined,
  361. location,
  362. });
  363. setShowMismatchConfirm(true);
  364. return;
  365. }
  366. }
  367. }
  368. doAssign(amsId, trayId);
  369. }, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
  370. const handleSlotClick = useCallback((amsId: number, trayId: number) => {
  371. if (isWaiting) return;
  372. if (requiresSlotConfirmation) {
  373. setSelectedSlot({ amsId, trayId });
  374. setStatusMessage(null);
  375. setStatusType(null);
  376. return;
  377. }
  378. prepareAssignment(amsId, trayId);
  379. }, [requiresSlotConfirmation, isWaiting, prepareAssignment]);
  380. const handleAssignSelectedSlot = useCallback(() => {
  381. if (!selectedSlot) return;
  382. prepareAssignment(selectedSlot.amsId, selectedSlot.trayId);
  383. }, [prepareAssignment, selectedSlot]);
  384. const handleConfirmMismatch = useCallback(() => {
  385. if (!pendingSlot) return;
  386. setShowMismatchConfirm(false);
  387. setMismatchDetails(null);
  388. doAssign(pendingSlot.amsId, pendingSlot.trayId);
  389. setPendingSlot(null);
  390. }, [pendingSlot, doAssign]);
  391. // Build single-slot items (HT + External)
  392. const singleSlots = useMemo(() => {
  393. const items: {
  394. key: string; label: string; amsId: number; trayId: number;
  395. tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
  396. effectiveFill: number | null;
  397. }[] = [];
  398. for (const unit of htAms) {
  399. const tray = unit.tray?.[0] || {
  400. id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
  401. tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
  402. cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
  403. };
  404. const invFill = fillOverrides[`${unit.id}-0`] ?? null;
  405. const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
  406. const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
  407. items.push({
  408. key: `ht-${unit.id}`, label: getAmsName(unit.id),
  409. amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
  410. nozzleSide: getNozzleSide(unit.id),
  411. effectiveFill: resolvedInvFill ?? amsFill,
  412. });
  413. }
  414. for (const extTray of vtTrays) {
  415. const extTrayId = extTray.id ?? 254;
  416. const extSlotTrayId = extTrayId - 254;
  417. const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
  418. const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
  419. const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
  420. items.push({
  421. key: `ext-${extTrayId}`,
  422. label: isDualNozzle
  423. ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
  424. : t('printers.ext', 'Ext'),
  425. amsId: 255, trayId: extSlotTrayId, tray: extTray,
  426. isEmpty: isTrayEmpty(extTray),
  427. nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
  428. effectiveFill: extResolvedInvFill ?? extAmsFill,
  429. });
  430. }
  431. return items;
  432. }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
  433. if (!isOpen) return null;
  434. const colorStyle = spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba);
  435. const selectedSlotLabel = selectedSlot
  436. ? getSlotLocationLabel(selectedSlot.amsId, selectedSlot.trayId)
  437. : null;
  438. const isDialogVariant = variant === 'dialog';
  439. const overlayClasses = isDialogVariant
  440. ? 'fixed inset-0 z-[60] flex items-center justify-center bg-black/45 dark:bg-black/70 p-2 sm:p-4 backdrop-blur-sm animate-fade-in'
  441. : 'fixed inset-0 z-[60] bg-bambu-dark';
  442. const dialogClasses = isDialogVariant
  443. ? 'bg-bambu-dark-secondary text-white font-sans w-full max-w-3xl h-[calc(100vh-1rem)] sm:h-[calc(100vh-2rem)] max-h-[820px] border border-[var(--border-color)] rounded-xl sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-slide-up'
  444. : 'w-full h-full bg-bambu-dark flex flex-col';
  445. return (
  446. <>
  447. <div
  448. className={overlayClasses}
  449. onClick={isDialogVariant && !isWaiting ? onClose : undefined}
  450. >
  451. <div
  452. ref={dialogRef}
  453. role="dialog"
  454. aria-modal="true"
  455. aria-labelledby="assign-to-ams-title"
  456. tabIndex={-1}
  457. className={dialogClasses}
  458. onClick={(event) => event.stopPropagation()}
  459. >
  460. {/* Header */}
  461. <div className="flex items-center justify-between bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
  462. <div className="flex items-center gap-3 min-w-0">
  463. <div className="w-7 h-7 rounded-full shrink-0" style={colorStyle} />
  464. <div className="min-w-0">
  465. <h2 id="assign-to-ams-title" className="text-sm font-semibold text-white truncate">
  466. {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
  467. <span className="font-normal text-bambu-gray-light ml-2">
  468. {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) ||
  469. t('spoolbuddy.spool.unknownColor')} &bull; {spool.brand} {spool.material}
  470. {spool.subtype && ` ${spool.subtype}`}
  471. </span>
  472. <span className="text-[10px] font-mono text-bambu-gray ml-2 shrink-0">#{spool.id}</span>
  473. </h2>
  474. </div>
  475. </div>
  476. <button
  477. onClick={onClose}
  478. disabled={isWaiting}
  479. aria-label={t('common.close', 'Close')}
  480. className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors shrink-0 disabled:opacity-50"
  481. >
  482. <X className="w-5 h-5" />
  483. </button>
  484. </div>
  485. {showPrinterPicker && (
  486. <div className="bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
  487. <label htmlFor="assign-spool-printer" className="flex items-center gap-2 text-sm font-medium text-white mb-2">
  488. <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">1</span>
  489. {t('printModal.selectPrinter')}
  490. </label>
  491. <select
  492. id="assign-spool-printer"
  493. aria-label={t('printModal.selectPrinter')}
  494. value={selectedPrinterId ?? ''}
  495. onChange={(event) => {
  496. setSelectedPrinterId(event.target.value ? Number(event.target.value) : null);
  497. setSelectedSlot(null);
  498. setStatusMessage(null);
  499. setStatusType(null);
  500. }}
  501. disabled={isWaiting || printersLoading}
  502. className="w-full bg-bambu-dark border border-[var(--border-color)] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-bambu-green disabled:opacity-50"
  503. >
  504. <option value="">
  505. {printersLoading
  506. ? t('common.loading')
  507. : activePrinters.length === 0
  508. ? t('common.noPrinters')
  509. : t('printModal.selectPrinter')}
  510. </option>
  511. {activePrinters.map((candidate) => (
  512. <option key={candidate.id} value={candidate.id}>
  513. {candidate.name}
  514. </option>
  515. ))}
  516. </select>
  517. </div>
  518. )}
  519. {/* Status message */}
  520. {statusMessage && (
  521. <div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
  522. statusType === 'info'
  523. ? 'bg-blue-50 border-blue-300 dark:bg-blue-500/10 dark:border-blue-500/40'
  524. : statusType === 'success'
  525. ? 'bg-green-50 border-green-300 dark:bg-green-500/10 dark:border-green-500/40'
  526. : 'bg-red-50 border-red-300 dark:bg-red-500/10 dark:border-red-500/40'
  527. }`}>
  528. {statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin shrink-0" />}
  529. {statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />}
  530. {statusType === 'error' && <XCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />}
  531. <span className={`text-sm ${
  532. statusType === 'info'
  533. ? 'text-blue-700 dark:text-blue-300'
  534. : statusType === 'success'
  535. ? 'text-green-700 dark:text-green-300'
  536. : 'text-red-700 dark:text-red-300'
  537. }`}>{statusMessage}</span>
  538. </div>
  539. )}
  540. <div className="flex-1 min-h-0 flex flex-col">
  541. {/* AMS slots */}
  542. <section className="flex min-h-[18rem] flex-col gap-3 bg-bambu-dark p-4 lg:min-h-0 lg:overflow-y-auto">
  543. {showPrinterPicker && (
  544. <div className="flex items-center gap-2 text-sm font-medium text-white shrink-0">
  545. <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">2</span>
  546. {t('printModal.selectSlot')}
  547. </div>
  548. )}
  549. {!targetPrinterId ? (
  550. <div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
  551. <div className="text-center text-bambu-gray">
  552. <Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
  553. <p className="text-base text-bambu-gray-light">{t('printModal.selectPrinter')}</p>
  554. </div>
  555. </div>
  556. ) : statusLoading ? (
  557. <div className="flex flex-1 items-center justify-center text-bambu-gray-light">
  558. <Loader2 className="mr-2 h-5 w-5 animate-spin text-bambu-green" />
  559. {t('common.loading')}
  560. </div>
  561. ) : !isConnected ? (
  562. <div className="flex flex-1 items-center justify-center rounded-xl border border-red-300 bg-red-50 p-8 dark:border-red-500/30 dark:bg-red-500/5">
  563. <p className="text-base text-red-700 dark:text-red-300">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
  564. </div>
  565. ) : amsUnits.length === 0 && vtTrays.length === 0 ? (
  566. <div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
  567. <div className="text-center text-bambu-gray">
  568. <Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
  569. <p className="text-base text-bambu-gray-light mb-1">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
  570. <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
  571. </div>
  572. </div>
  573. ) : (
  574. <>
  575. {/* Regular AMS — 2-col grid */}
  576. {regularAms.length > 0 && (
  577. <div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
  578. {regularAms.map((unit) => (
  579. <AmsUnitCard
  580. key={unit.id}
  581. unit={unit}
  582. activeSlot={
  583. selectedSlot?.amsId === unit.id
  584. ? selectedSlot.trayId
  585. : currentAssignment?.ams_id === unit.id
  586. ? (currentAssignment.tray_id ?? null)
  587. : null
  588. }
  589. onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
  590. isDualNozzle={isDualNozzle}
  591. nozzleSide={getNozzleSide(unit.id)}
  592. thresholds={amsThresholds}
  593. fillOverrides={fillOverrides}
  594. />
  595. ))}
  596. </div>
  597. )}
  598. {/* Single-slot items (HT + External) */}
  599. {singleSlots.length > 0 && (
  600. <div className="flex flex-wrap gap-2 shrink-0">
  601. {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
  602. const color = trayColorToCSS(tray.tray_color);
  603. const isActive = selectedSlot
  604. ? selectedSlot.amsId === amsId && selectedSlot.trayId === trayId
  605. : !!currentAssignment && currentAssignment.ams_id === amsId && currentAssignment.tray_id === trayId;
  606. return (
  607. <button
  608. key={key}
  609. type="button"
  610. aria-pressed={isActive}
  611. title={getSlotLocationLabel(amsId, trayId)}
  612. onClick={() => handleSlotClick(amsId, trayId)}
  613. className={`bg-bambu-dark-secondary border rounded-lg px-3 py-2 hover:bg-bambu-dark-tertiary transition-all flex items-center gap-2 ${
  614. isActive ? 'ring-2 ring-bambu-green border-bambu-green bg-bambu-green/10' : 'border-[var(--border-color)]'
  615. } ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
  616. >
  617. <div className="relative w-10 h-10 shrink-0">
  618. {isEmpty ? (
  619. <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
  620. <div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
  621. </div>
  622. ) : (
  623. <svg viewBox="0 0 56 56" className="w-full h-full">
  624. <circle cx="28" cy="28" r="26" fill={color} />
  625. <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
  626. <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
  627. <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
  628. <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
  629. </svg>
  630. )}
  631. </div>
  632. <div className="min-w-0 text-left">
  633. <div className="flex items-center gap-1">
  634. <span className="text-xs text-bambu-gray font-medium">{label}</span>
  635. {nozzleSide && <NozzleBadge side={nozzleSide} />}
  636. </div>
  637. <div className="text-sm text-white truncate">
  638. {isEmpty ? t('ams.empty') : tray.tray_type || '?'}
  639. </div>
  640. </div>
  641. {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
  642. <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
  643. <div
  644. className="w-full rounded-full"
  645. style={{
  646. height: `${effectiveFill}%`,
  647. backgroundColor: getFillBarColor(effectiveFill),
  648. }}
  649. />
  650. </div>
  651. )}
  652. </button>
  653. );
  654. })}
  655. </div>
  656. )}
  657. </>
  658. )}
  659. </section>
  660. </div>
  661. {/* Footer */}
  662. <div className="flex items-center gap-3 bg-bambu-dark-secondary px-5 py-3 border-t border-[var(--border-color)] shrink-0">
  663. {requiresSlotConfirmation && (
  664. <div className="min-w-0 flex-1 text-sm">
  665. <span className="text-bambu-gray">{t('printModal.selectSlot')}: </span>
  666. <span className={selectedSlotLabel ? 'font-medium text-white' : 'text-bambu-gray'}>
  667. {selectedSlotLabel || '—'}
  668. </span>
  669. </div>
  670. )}
  671. <button
  672. onClick={onClose}
  673. disabled={isWaiting}
  674. className="px-5 py-2.5 rounded-lg border border-[var(--border-color)] text-sm font-medium bg-bambu-dark-tertiary text-white hover:bg-bambu-dark transition-colors min-h-[44px] disabled:opacity-50"
  675. >
  676. {statusType === 'success' ? t('common.close') : t('common.cancel')}
  677. </button>
  678. {requiresSlotConfirmation && (
  679. <button
  680. type="button"
  681. onClick={handleAssignSelectedSlot}
  682. disabled={!targetPrinterId || !selectedSlot || isWaiting || statusType === 'success'}
  683. className="inline-flex min-h-[44px] items-center gap-2 rounded-lg bg-bambu-green px-5 py-2.5 text-sm font-semibold text-[#ffffff] transition-colors hover:bg-bambu-green-dark disabled:cursor-not-allowed disabled:opacity-40"
  684. >
  685. {isWaiting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
  686. {t('inventory.assignSpool')}
  687. </button>
  688. )}
  689. </div>
  690. </div>
  691. </div>
  692. {showMismatchConfirm && mismatchDetails && (() => {
  693. let message = '';
  694. if (mismatchDetails.type === 'material') {
  695. message = t('inventory.assignMismatchMessage', {
  696. spoolMaterial: mismatchDetails.spoolMaterial,
  697. trayMaterial: mismatchDetails.trayMaterial,
  698. location: mismatchDetails.location,
  699. });
  700. } else if (mismatchDetails.type === 'partial') {
  701. message = t('inventory.assignPartialMismatchMessage', {
  702. spoolMaterial: mismatchDetails.spoolMaterial,
  703. trayMaterial: mismatchDetails.trayMaterial,
  704. location: mismatchDetails.location,
  705. });
  706. } else if (mismatchDetails.type === 'material_profile') {
  707. message = `${t('inventory.assignMismatchMessage', {
  708. spoolMaterial: mismatchDetails.spoolMaterial,
  709. trayMaterial: mismatchDetails.trayMaterial,
  710. location: mismatchDetails.location,
  711. })}\n\n${t('inventory.assignProfileMismatchMessage', {
  712. spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
  713. trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
  714. location: mismatchDetails.location,
  715. })}`;
  716. } else if (mismatchDetails.type === 'partial_profile') {
  717. message = `${t('inventory.assignPartialMismatchMessage', {
  718. spoolMaterial: mismatchDetails.spoolMaterial,
  719. trayMaterial: mismatchDetails.trayMaterial,
  720. location: mismatchDetails.location,
  721. })}\n\n${t('inventory.assignProfileMismatchMessage', {
  722. spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
  723. trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
  724. location: mismatchDetails.location,
  725. })}`;
  726. }
  727. // Always tell the user the AMS slot will be reconfigured — without
  728. // this, "Assign Anyway" reads as a no-op confirmation when the
  729. // backend in fact pushes the spool profile on every assign (#1552).
  730. message = `${message}\n\n${t('inventory.assignReconfigureNote')}`;
  731. return (
  732. <ConfirmModal
  733. title={t('inventory.assignMismatchTitle')}
  734. message={message}
  735. confirmText={t('inventory.assignMismatchConfirm')}
  736. variant="warning"
  737. overlayZIndex="z-[70]"
  738. isLoading={configureMutation.isPending}
  739. onConfirm={handleConfirmMismatch}
  740. onCancel={() => {
  741. if (!configureMutation.isPending) {
  742. setShowMismatchConfirm(false);
  743. setPendingSlot(null);
  744. setMismatchDetails(null);
  745. }
  746. }}
  747. />
  748. );
  749. })()}
  750. </>
  751. );
  752. }