AssignToAmsModal.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  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, 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 } from '../../utils/colors';
  11. function getAmsName(id: number): string {
  12. if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
  13. if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
  14. return `AMS ${id}`;
  15. }
  16. function isTrayEmpty(tray: AMSTray): boolean {
  17. return !tray.tray_type || tray.tray_type === '';
  18. }
  19. function trayColorToCSS(color: string | null): string {
  20. if (!color) return '#808080';
  21. return `#${color.slice(0, 6)}`;
  22. }
  23. // --- Material/profile mismatch helpers (pure functions, no component state) ---
  24. const normalizeValue = (value: string | undefined | null) =>
  25. (value ?? '').trim().toUpperCase();
  26. function checkMaterialMatch(
  27. spoolMaterial: string | undefined | null,
  28. trayMaterial: string | undefined | null
  29. ): 'exact' | 'partial' | 'none' {
  30. const normalizedSpool = normalizeValue(spoolMaterial);
  31. const normalizedTray = normalizeValue(trayMaterial);
  32. if (!normalizedSpool || !normalizedTray) return 'none';
  33. if (normalizedSpool === normalizedTray) return 'exact';
  34. if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
  35. return 'partial';
  36. }
  37. return 'none';
  38. }
  39. function checkProfileMatch(
  40. spoolProfile: string | undefined | null,
  41. trayProfile: string | undefined | null
  42. ): boolean {
  43. const normalizedSpoolProfile = normalizeValue(spoolProfile);
  44. const normalizedTrayProfile = normalizeValue(trayProfile);
  45. if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
  46. return normalizedSpoolProfile === normalizedTrayProfile;
  47. }
  48. interface AssignToAmsModalProps {
  49. isOpen: boolean;
  50. onClose: () => void;
  51. spool: InventorySpool;
  52. printerId: number | null;
  53. spoolmanMode?: boolean;
  54. }
  55. export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) {
  56. const { t } = useTranslation();
  57. const queryClient = useQueryClient();
  58. const [statusMessage, setStatusMessage] = useState<string | null>(null);
  59. const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
  60. const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
  61. // Profile-only mismatches no longer trigger the popup — the backend
  62. // pushes the spool's slicer profile to the AMS slot on every assign
  63. // anyway, so the warning was friction without benefit (#1552). Material
  64. // mismatch still warns because firmware can refuse a print when type
  65. // doesn't match.
  66. const [mismatchDetails, setMismatchDetails] = useState<{
  67. type: 'material' | 'partial' | 'material_profile' | 'partial_profile';
  68. spoolMaterial: string;
  69. trayMaterial: string;
  70. spoolProfile?: string;
  71. trayProfile?: string;
  72. location: string;
  73. } | null>(null);
  74. const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);
  75. useEffect(() => {
  76. if (isOpen) {
  77. setStatusMessage(null);
  78. setStatusType(null);
  79. setShowMismatchConfirm(false);
  80. setMismatchDetails(null);
  81. setPendingSlot(null);
  82. }
  83. }, [isOpen]);
  84. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  85. if (e.key === 'Escape') onClose();
  86. }, [onClose]);
  87. useEffect(() => {
  88. if (isOpen) document.addEventListener('keydown', handleKeyDown);
  89. return () => document.removeEventListener('keydown', handleKeyDown);
  90. }, [isOpen, handleKeyDown]);
  91. const { data: status } = useQuery<PrinterStatus>({
  92. queryKey: ['printerStatus', printerId],
  93. queryFn: () => api.getPrinterStatus(printerId!),
  94. enabled: isOpen && printerId !== null,
  95. refetchInterval: 5000,
  96. });
  97. const { data: printer } = useQuery({
  98. queryKey: ['printer', printerId],
  99. queryFn: () => api.getPrinter(printerId!),
  100. enabled: isOpen && printerId !== null,
  101. });
  102. const { data: settings } = useQuery({
  103. queryKey: ['settings'],
  104. queryFn: () => api.getSettings(),
  105. enabled: isOpen,
  106. staleTime: 5 * 60 * 1000,
  107. });
  108. const { data: assignments } = useQuery({
  109. queryKey: ['spool-assignments', printerId],
  110. queryFn: () => api.getAssignments(printerId!),
  111. enabled: isOpen && printerId !== null,
  112. staleTime: 30 * 1000,
  113. });
  114. const { data: spoolmanAssignments = [] } = useQuery({
  115. queryKey: ['spoolman-slot-assignments', printerId],
  116. queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined),
  117. enabled: isOpen && !!spoolmanMode && printerId !== null,
  118. staleTime: 30 * 1000,
  119. });
  120. const currentAssignment = spoolmanMode
  121. ? spoolmanAssignments.find(a => a.spoolman_spool_id === spool.id)
  122. : undefined;
  123. // Build fill-level override map from inventory assignments
  124. const fillOverrides = useMemo(() => {
  125. const map: Record<string, number> = {};
  126. if (!assignments) return map;
  127. for (const a of assignments) {
  128. const sp = a.spool;
  129. if (sp && sp.label_weight > 0 && sp.weight_used != null) {
  130. const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
  131. map[`${a.ams_id}-${a.tray_id}`] = fill;
  132. }
  133. }
  134. return map;
  135. }, [assignments]);
  136. const amsThresholds: AmsThresholds | undefined = settings ? {
  137. humidityGood: Number(settings.ams_humidity_good) || 40,
  138. humidityFair: Number(settings.ams_humidity_fair) || 60,
  139. tempGood: Number(settings.ams_temp_good) || 28,
  140. tempFair: Number(settings.ams_temp_fair) || 35,
  141. } : undefined;
  142. const isConnected = status?.connected ?? false;
  143. const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
  144. const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
  145. const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
  146. const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
  147. const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
  148. const cachedAmsExtruderMap = useRef<Record<string, number>>({});
  149. useEffect(() => {
  150. if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
  151. cachedAmsExtruderMap.current = status.ams_extruder_map;
  152. }
  153. }, [status?.ams_extruder_map]);
  154. const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
  155. ? status.ams_extruder_map
  156. : cachedAmsExtruderMap.current;
  157. const ftsInstalled = status?.fila_switch?.installed === true;
  158. const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
  159. if (!isDualNozzle) return null;
  160. const mappedExtruderId = amsExtruderMap[String(amsId)];
  161. if (mappedExtruderId !== undefined) return mappedExtruderId === 1 ? 'L' : 'R';
  162. // With a Filament Track Switch every AMS reports extruder 0xE and reaches
  163. // both nozzles through the switch, so there is no side to show. The unit-id
  164. // guess below would label them all "R" — it exists only for dual-nozzle
  165. // printers that never sent a map at all. See PrintersPage.amsSideBadge,
  166. // which shows the switch inlet in place of L/R on the printer card.
  167. if (ftsInstalled) return null;
  168. const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
  169. return normalizedId === 1 ? 'L' : 'R';
  170. }, [isDualNozzle, amsExtruderMap, ftsInstalled]);
  171. // Assign spool to AMS slot — single API call, backend handles both DB record
  172. // AND MQTT auto-configuration. When the target slot is currently empty, the
  173. // backend persists the assignment and skips the MQTT publish (firmware drops
  174. // it anyway); on_ams_change re-fires the full configuration when filament is
  175. // later inserted. The response's `pending_config` flag distinguishes that
  176. // from the immediate-apply path so we can adjust the success toast.
  177. const configureMutation = useMutation({
  178. mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
  179. if (!printerId) throw new Error('No printer selected');
  180. if (spoolmanMode) {
  181. return await api.assignSpoolmanSlot({
  182. spoolman_spool_id: spool.id,
  183. printer_id: printerId,
  184. ams_id: amsId,
  185. tray_id: trayId,
  186. });
  187. }
  188. return await api.assignSpool({
  189. spool_id: spool.id,
  190. printer_id: printerId,
  191. ams_id: amsId,
  192. tray_id: trayId,
  193. });
  194. },
  195. onSuccess: (assignment) => {
  196. setStatusType('success');
  197. // pending_config only exists on SpoolAssignment (the local-inventory path);
  198. // the Spoolman path returns InventorySpool which always implies immediate apply.
  199. const pendingConfig = assignment && 'pending_config' in assignment && assignment.pending_config;
  200. if (pendingConfig) {
  201. setStatusMessage(
  202. t(
  203. 'spoolbuddy.modal.assignPendingInsert',
  204. 'Assigned. Slot will configure when you insert the spool.',
  205. ),
  206. );
  207. } else {
  208. setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
  209. }
  210. queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
  211. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  212. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  213. setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
  214. },
  215. onError: (err) => {
  216. setStatusType('error');
  217. setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
  218. },
  219. });
  220. const isWaiting = configureMutation.isPending;
  221. const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
  222. if (amsId === 254 || amsId === 255) {
  223. const extTrayId = amsId === 254 ? 254 : 254 + trayId;
  224. return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
  225. }
  226. const unit = amsUnits.find(u => u.id === amsId);
  227. return unit?.tray?.find(t => t.id === trayId) || null;
  228. }, [amsUnits, vtTrays]);
  229. const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
  230. if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
  231. if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
  232. if (amsId === 254) return t('printers.extL', 'Ext-L');
  233. return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');
  234. }, [t, isDualNozzle]);
  235. const doAssign = useCallback((amsId: number, trayId: number) => {
  236. setStatusType('info');
  237. setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
  238. configureMutation.mutate({ amsId, trayId });
  239. }, [configureMutation, t]);
  240. const handleSlotClick = useCallback((amsId: number, trayId: number) => {
  241. if (isWaiting) return;
  242. if (!settings?.disable_filament_warnings) {
  243. const tray = getTrayForSlot(amsId, trayId);
  244. if (tray && !isTrayEmpty(tray)) {
  245. const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';
  246. const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
  247. const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
  248. const trayProfile = tray.tray_type || '';
  249. const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
  250. if (materialMatchResult !== 'exact') {
  251. let mismatchType: 'material' | 'partial' | 'material_profile' | 'partial_profile';
  252. if (materialMatchResult === 'none' && !profileMatches) {
  253. mismatchType = 'material_profile';
  254. } else if (materialMatchResult === 'partial' && !profileMatches) {
  255. mismatchType = 'partial_profile';
  256. } else if (materialMatchResult === 'none') {
  257. mismatchType = 'material';
  258. } else {
  259. mismatchType = 'partial';
  260. }
  261. const location = getSlotLocationLabel(amsId, trayId);
  262. setPendingSlot({ amsId, trayId });
  263. setMismatchDetails({
  264. type: mismatchType,
  265. spoolMaterial: spool.material || '',
  266. trayMaterial: trayMaterial || '',
  267. spoolProfile: spoolProfile || undefined,
  268. trayProfile: trayProfile || undefined,
  269. location,
  270. });
  271. setShowMismatchConfirm(true);
  272. return;
  273. }
  274. }
  275. }
  276. doAssign(amsId, trayId);
  277. }, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
  278. const handleConfirmMismatch = useCallback(() => {
  279. if (!pendingSlot) return;
  280. setShowMismatchConfirm(false);
  281. setMismatchDetails(null);
  282. doAssign(pendingSlot.amsId, pendingSlot.trayId);
  283. setPendingSlot(null);
  284. }, [pendingSlot, doAssign]);
  285. // Build single-slot items (HT + External)
  286. const singleSlots = useMemo(() => {
  287. const items: {
  288. key: string; label: string; amsId: number; trayId: number;
  289. tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
  290. effectiveFill: number | null;
  291. }[] = [];
  292. for (const unit of htAms) {
  293. const tray = unit.tray?.[0] || {
  294. id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
  295. tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
  296. cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
  297. };
  298. const invFill = fillOverrides[`${unit.id}-0`] ?? null;
  299. const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
  300. const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
  301. items.push({
  302. key: `ht-${unit.id}`, label: getAmsName(unit.id),
  303. amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
  304. nozzleSide: getNozzleSide(unit.id),
  305. effectiveFill: resolvedInvFill ?? amsFill,
  306. });
  307. }
  308. for (const extTray of vtTrays) {
  309. const extTrayId = extTray.id ?? 254;
  310. const extSlotTrayId = extTrayId - 254;
  311. const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
  312. const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
  313. const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
  314. items.push({
  315. key: `ext-${extTrayId}`,
  316. label: isDualNozzle
  317. ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
  318. : t('printers.ext', 'Ext'),
  319. amsId: 255, trayId: extSlotTrayId, tray: extTray,
  320. isEmpty: isTrayEmpty(extTray),
  321. nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
  322. effectiveFill: extResolvedInvFill ?? extAmsFill,
  323. });
  324. }
  325. return items;
  326. }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
  327. if (!isOpen) return null;
  328. const colorStyle = getSwatchStyle(spool.rgba);
  329. return (
  330. <>
  331. <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
  332. {/* Header */}
  333. <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
  334. <div className="flex items-center gap-3 min-w-0">
  335. <div className="w-7 h-7 rounded-full shrink-0" style={colorStyle} />
  336. <div className="min-w-0">
  337. <h2 className="text-sm font-semibold text-zinc-100 truncate">
  338. {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
  339. <span className="font-normal text-zinc-500 ml-2">
  340. {spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
  341. </span>
  342. <span className="text-[10px] font-mono text-zinc-500 ml-2 shrink-0">#{spool.id}</span>
  343. </h2>
  344. </div>
  345. </div>
  346. <button
  347. onClick={onClose}
  348. disabled={isWaiting}
  349. className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50"
  350. >
  351. <X className="w-5 h-5" />
  352. </button>
  353. </div>
  354. {/* Status message */}
  355. {statusMessage && (
  356. <div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
  357. statusType === 'info'
  358. ? 'bg-blue-500/10 border-blue-500/40'
  359. : statusType === 'success'
  360. ? 'bg-green-500/10 border-green-500/40'
  361. : 'bg-red-500/10 border-red-500/40'
  362. }`}>
  363. {statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />}
  364. {statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />}
  365. {statusType === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
  366. <span className={`text-sm ${
  367. statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
  368. }`}>{statusMessage}</span>
  369. </div>
  370. )}
  371. {/* AMS slots */}
  372. <div className="flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto">
  373. {!isConnected && printerId ? (
  374. <div className="flex-1 flex items-center justify-center">
  375. <div className="text-center text-white/50">
  376. <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
  377. </div>
  378. </div>
  379. ) : amsUnits.length === 0 && vtTrays.length === 0 ? (
  380. <div className="flex-1 flex items-center justify-center">
  381. <div className="text-center text-white/50">
  382. <Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
  383. <p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
  384. <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
  385. </div>
  386. </div>
  387. ) : (
  388. <>
  389. {/* Regular AMS — 2-col grid */}
  390. {regularAms.length > 0 && (
  391. <div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
  392. {regularAms.map((unit) => (
  393. <AmsUnitCard
  394. key={unit.id}
  395. unit={unit}
  396. activeSlot={currentAssignment?.ams_id === unit.id ? (currentAssignment.tray_id ?? null) : null}
  397. onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
  398. isDualNozzle={isDualNozzle}
  399. nozzleSide={getNozzleSide(unit.id)}
  400. thresholds={amsThresholds}
  401. fillOverrides={fillOverrides}
  402. />
  403. ))}
  404. </div>
  405. )}
  406. {/* Single-slot items (HT + External) */}
  407. {singleSlots.length > 0 && (
  408. <div className="flex gap-2 shrink-0">
  409. {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
  410. const color = trayColorToCSS(tray.tray_color);
  411. const isActive = !!currentAssignment &&
  412. currentAssignment.ams_id === amsId &&
  413. currentAssignment.tray_id === trayId;
  414. return (
  415. <div
  416. key={key}
  417. onClick={() => handleSlotClick(amsId, trayId)}
  418. className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
  419. isActive ? 'ring-2 ring-bambu-green' : ''
  420. } ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
  421. >
  422. <div className="relative w-10 h-10 shrink-0">
  423. {isEmpty ? (
  424. <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
  425. <div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
  426. </div>
  427. ) : (
  428. <svg viewBox="0 0 56 56" className="w-full h-full">
  429. <circle cx="28" cy="28" r="26" fill={color} />
  430. <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
  431. <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
  432. <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
  433. <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
  434. </svg>
  435. )}
  436. </div>
  437. <div className="min-w-0">
  438. <div className="flex items-center gap-1">
  439. <span className="text-xs text-white/50 font-medium">{label}</span>
  440. {nozzleSide && <NozzleBadge side={nozzleSide} />}
  441. </div>
  442. <div className="text-sm text-white/80 truncate">
  443. {isEmpty ? 'Empty' : tray.tray_type || '?'}
  444. </div>
  445. </div>
  446. {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
  447. <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
  448. <div
  449. className="w-full rounded-full"
  450. style={{
  451. height: `${effectiveFill}%`,
  452. backgroundColor: getFillBarColor(effectiveFill),
  453. }}
  454. />
  455. </div>
  456. )}
  457. </div>
  458. );
  459. })}
  460. </div>
  461. )}
  462. </>
  463. )}
  464. </div>
  465. {/* Footer */}
  466. <div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
  467. <button
  468. onClick={onClose}
  469. disabled={isWaiting}
  470. className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50"
  471. >
  472. {statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
  473. </button>
  474. </div>
  475. </div>
  476. {showMismatchConfirm && mismatchDetails && (() => {
  477. let message = '';
  478. if (mismatchDetails.type === 'material') {
  479. message = t('inventory.assignMismatchMessage', {
  480. spoolMaterial: mismatchDetails.spoolMaterial,
  481. trayMaterial: mismatchDetails.trayMaterial,
  482. location: mismatchDetails.location,
  483. });
  484. } else if (mismatchDetails.type === 'partial') {
  485. message = t('inventory.assignPartialMismatchMessage', {
  486. spoolMaterial: mismatchDetails.spoolMaterial,
  487. trayMaterial: mismatchDetails.trayMaterial,
  488. location: mismatchDetails.location,
  489. });
  490. } else if (mismatchDetails.type === 'material_profile') {
  491. message = `${t('inventory.assignMismatchMessage', {
  492. spoolMaterial: mismatchDetails.spoolMaterial,
  493. trayMaterial: mismatchDetails.trayMaterial,
  494. location: mismatchDetails.location,
  495. })}\n\n${t('inventory.assignProfileMismatchMessage', {
  496. spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
  497. trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
  498. location: mismatchDetails.location,
  499. })}`;
  500. } else if (mismatchDetails.type === 'partial_profile') {
  501. message = `${t('inventory.assignPartialMismatchMessage', {
  502. spoolMaterial: mismatchDetails.spoolMaterial,
  503. trayMaterial: mismatchDetails.trayMaterial,
  504. location: mismatchDetails.location,
  505. })}\n\n${t('inventory.assignProfileMismatchMessage', {
  506. spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
  507. trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
  508. location: mismatchDetails.location,
  509. })}`;
  510. }
  511. // Always tell the user the AMS slot will be reconfigured — without
  512. // this, "Assign Anyway" reads as a no-op confirmation when the
  513. // backend in fact pushes the spool profile on every assign (#1552).
  514. message = `${message}\n\n${t('inventory.assignReconfigureNote')}`;
  515. return (
  516. <ConfirmModal
  517. title={t('inventory.assignMismatchTitle')}
  518. message={message}
  519. confirmText={t('inventory.assignMismatchConfirm')}
  520. variant="warning"
  521. isLoading={configureMutation.isPending}
  522. onConfirm={handleConfirmMismatch}
  523. onCancel={() => {
  524. if (!configureMutation.isPending) {
  525. setShowMismatchConfirm(false);
  526. setPendingSlot(null);
  527. setMismatchDetails(null);
  528. }
  529. }}
  530. />
  531. );
  532. })()}
  533. </>
  534. );
  535. }