QueuePage.tsx 100 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386
  1. import { useState, useMemo, useEffect, useCallback } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { Link } from 'react-router-dom';
  5. import {
  6. DndContext,
  7. DragOverlay,
  8. closestCenter,
  9. KeyboardSensor,
  10. PointerSensor,
  11. useSensor,
  12. useSensors,
  13. } from '@dnd-kit/core';
  14. import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
  15. import {
  16. SortableContext,
  17. sortableKeyboardCoordinates,
  18. useSortable,
  19. verticalListSortingStrategy,
  20. } from '@dnd-kit/sortable';
  21. import { CSS } from '@dnd-kit/utilities';
  22. import {
  23. Clock,
  24. Trash2,
  25. Play,
  26. X,
  27. CheckCircle,
  28. XCircle,
  29. AlertCircle,
  30. Calendar,
  31. Printer,
  32. GripVertical,
  33. SkipForward,
  34. ExternalLink,
  35. Power,
  36. StopCircle,
  37. Pencil,
  38. RefreshCw,
  39. Timer,
  40. ListOrdered,
  41. Layers,
  42. ArrowUp,
  43. ArrowDown,
  44. Hand,
  45. Check,
  46. CheckSquare,
  47. Square,
  48. User,
  49. Pause,
  50. Weight,
  51. ChevronDown,
  52. ChevronRight,
  53. List,
  54. GanttChart,
  55. Code,
  56. Snail,
  57. Package,
  58. PackageOpen,
  59. Ungroup,
  60. Ban,
  61. } from 'lucide-react';
  62. import { api, ApiError } from '../api/client';
  63. import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
  64. import { getBedTypeInfo } from '../utils/bedType';
  65. import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
  66. import { Card } from '../components/Card';
  67. import { Button } from '../components/Button';
  68. import { ConfirmModal } from '../components/ConfirmModal';
  69. import { PrintModal } from '../components/PrintModal';
  70. import { useToast } from '../contexts/ToastContext';
  71. import { useAuth } from '../contexts/AuthContext';
  72. import { QueueStatsBar } from '../components/QueueStatsBar';
  73. import { CompactHistoryRow } from '../components/CompactHistoryRow';
  74. import { QueueTimelineView } from '../components/QueueTimelineView';
  75. function formatWeight(g: number, useKg = false): string {
  76. if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
  77. return `${Math.round(g)}g`;
  78. }
  79. function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
  80. // Special case: pending with waiting_reason shows as "Waiting"
  81. if (status === 'pending' && waitingReason) {
  82. return (
  83. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
  84. <Clock className="w-3.5 h-3.5" />
  85. {t('queue.status.waiting')}
  86. </span>
  87. );
  88. }
  89. // Special case: printing but printer is paused
  90. if (status === 'printing' && printerState === 'PAUSE') {
  91. return (
  92. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20">
  93. <Pause className="w-3.5 h-3.5" />
  94. {t('queue.status.paused')}
  95. </span>
  96. );
  97. }
  98. const config = {
  99. pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
  100. printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
  101. completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
  102. failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
  103. skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
  104. cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
  105. };
  106. const { icon: Icon, color, label } = config[status];
  107. return (
  108. <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
  109. <Icon className="w-3.5 h-3.5" />
  110. {label}
  111. </span>
  112. );
  113. }
  114. // Bulk edit modal for multiple queue items
  115. function BulkEditModal({
  116. selectedCount,
  117. printers,
  118. onSave,
  119. onClose,
  120. isSaving,
  121. canControlPrinter,
  122. t,
  123. }: {
  124. selectedCount: number;
  125. printers: { id: number; name: string; nozzle_count?: number }[];
  126. onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
  127. onClose: () => void;
  128. isSaving: boolean;
  129. canControlPrinter: boolean;
  130. t: (key: string, options?: Record<string, unknown>) => string;
  131. }) {
  132. const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
  133. const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
  134. const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
  135. const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
  136. const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');
  137. const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');
  138. const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
  139. const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
  140. const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
  141. const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
  142. const [nozzleOffsetCali, setNozzleOffsetCali] = useState<boolean | 'unchanged'>('unchanged');
  143. // Show the dual-nozzle-only toggle when the user has at least one
  144. // dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle
  145. // queues never see it — the MQTT layer ignores the field anyway.
  146. const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2);
  147. const handleSave = () => {
  148. const data: Partial<PrintQueueBulkUpdate> = {};
  149. if (printerId !== 'unchanged') data.printer_id = printerId;
  150. if (manualStart !== 'unchanged') data.manual_start = manualStart;
  151. if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
  152. if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
  153. if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
  154. if (flowCali !== 'unchanged') data.flow_cali = flowCali;
  155. if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
  156. if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
  157. if (timelapse !== 'unchanged') data.timelapse = timelapse;
  158. if (useAms !== 'unchanged') data.use_ams = useAms;
  159. if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
  160. onSave(data);
  161. };
  162. const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
  163. requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
  164. vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
  165. nozzleOffsetCali !== 'unchanged';
  166. return (
  167. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
  168. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
  169. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  170. <h2 className="text-lg font-semibold text-white">
  171. {t('queue.bulkEdit.title', { count: selectedCount })}
  172. </h2>
  173. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  174. <X className="w-5 h-5 text-bambu-gray" />
  175. </button>
  176. </div>
  177. <div className="p-4 space-y-4">
  178. <p className="text-sm text-bambu-gray">
  179. {t('queue.bulkEdit.description')}
  180. </p>
  181. {/* Printer Assignment */}
  182. <div>
  183. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
  184. <select
  185. value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
  186. onChange={(e) => {
  187. const val = e.target.value;
  188. if (val === 'unchanged') setPrinterId('unchanged');
  189. else if (val === 'null') setPrinterId(null);
  190. else setPrinterId(Number(val));
  191. }}
  192. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  193. >
  194. <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
  195. <option value="null">{t('queue.filter.unassigned')}</option>
  196. {printers.map(p => (
  197. <option key={p.id} value={p.id}>{p.name}</option>
  198. ))}
  199. </select>
  200. </div>
  201. {/* Queue Options */}
  202. <div>
  203. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
  204. <div className="space-y-2">
  205. <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
  206. <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />
  207. <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
  208. </div>
  209. </div>
  210. {/* Print Options */}
  211. <div>
  212. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
  213. <div className="space-y-2">
  214. <TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
  215. <TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
  216. <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
  217. <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
  218. <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
  219. <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
  220. {hasDualNozzlePrinter && (
  221. <TriStateToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
  222. )}
  223. </div>
  224. </div>
  225. </div>
  226. <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  227. <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
  228. <Button
  229. onClick={handleSave}
  230. disabled={!hasChanges || isSaving}
  231. >
  232. {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
  233. </Button>
  234. </div>
  235. </div>
  236. </div>
  237. );
  238. }
  239. // Tri-state toggle for bulk edit (unchanged / on / off)
  240. function TriStateToggle({
  241. label,
  242. value,
  243. onChange,
  244. disabled,
  245. t,
  246. }: {
  247. label: string;
  248. value: boolean | 'unchanged';
  249. onChange: (val: boolean | 'unchanged') => void;
  250. disabled?: boolean;
  251. t: (key: string) => string;
  252. }) {
  253. return (
  254. <div className={`flex items-center justify-between py-1 ${disabled ? 'opacity-50' : ''}`}>
  255. <span className="text-sm text-bambu-gray">{label}</span>
  256. <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
  257. <button
  258. onClick={() => onChange('unchanged')}
  259. disabled={disabled}
  260. className={`px-2 py-1 text-xs rounded ${value === 'unchanged' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  261. >
  262. </button>
  263. <button
  264. onClick={() => onChange(false)}
  265. disabled={disabled}
  266. className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  267. >
  268. {t('common.off')}
  269. </button>
  270. <button
  271. onClick={() => onChange(true)}
  272. disabled={disabled}
  273. className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  274. >
  275. {t('common.on')}
  276. </button>
  277. </div>
  278. </div>
  279. );
  280. }
  281. // Sortable queue item for drag and drop
  282. function SortableQueueItem({
  283. item,
  284. position,
  285. onEdit,
  286. onCancel,
  287. onRemove,
  288. onStop,
  289. onRequeue,
  290. onStart,
  291. timeFormat = 'system',
  292. isSelected = false,
  293. onToggleSelect,
  294. hasPermission,
  295. canModify,
  296. printerState,
  297. t,
  298. }: {
  299. item: PrintQueueItem;
  300. position?: number;
  301. onEdit: () => void;
  302. onCancel: () => void;
  303. onRemove: () => void;
  304. onStop: () => void;
  305. onRequeue: () => void;
  306. onStart: () => void;
  307. timeFormat?: TimeFormat;
  308. isSelected?: boolean;
  309. onToggleSelect?: () => void;
  310. hasPermission: (permission: Permission) => boolean;
  311. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  312. printerState?: string | null;
  313. t: (key: string, options?: Record<string, unknown>) => string;
  314. }) {
  315. // Fetch printer status every 30 seconds while printing to monitor progress
  316. const { data: status } = useQuery({
  317. queryKey: ['printerStatus', item.printer_id],
  318. queryFn: () => api.getPrinterStatus(item.printer_id!),
  319. refetchInterval: 30000,
  320. enabled: item.printer_id != null && printerState === 'printing',
  321. });
  322. // Determine if we're printing a library file
  323. const isLibraryFile = !!item.library_file_id && !item.archive_id;
  324. // Fetch archive plate details. Skip when the linked archive has been
  325. // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
  326. // /plates endpoint just 404-storms the queue page.
  327. const { data: archivePlatesData } = useQuery({
  328. queryKey: ['archive-plates', item.archive_id],
  329. queryFn: () => api.getArchivePlates(item.archive_id!),
  330. enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
  331. });
  332. // Fetch library file plate details
  333. const { data: libraryPlatesData } = useQuery({
  334. queryKey: ['library-file-plates', item.library_file_id],
  335. queryFn: () => api.getLibraryFilePlates(item.library_file_id!),
  336. enabled: isLibraryFile && !!item.library_file_id,
  337. });
  338. // Combine plates data from either source
  339. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  340. const plates = platesData?.plates ?? [];
  341. const canReorder = hasPermission('queue:reorder');
  342. const {
  343. attributes,
  344. listeners,
  345. setNodeRef,
  346. transform,
  347. transition,
  348. isDragging,
  349. } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
  350. const style = {
  351. transform: CSS.Transform.toString(transform),
  352. transition,
  353. };
  354. const isPrinting = item.status === 'printing';
  355. const isPending = item.status === 'pending';
  356. const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
  357. const isMobileSelectable = isPending && onToggleSelect;
  358. return (
  359. <div
  360. ref={setNodeRef}
  361. style={style}
  362. className={`
  363. group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
  364. border-l-[3px] ${
  365. isPrinting ? 'border-l-blue-500' :
  366. isPending ? 'border-l-yellow-500' :
  367. item.status === 'completed' ? 'border-l-emerald-500' :
  368. item.status === 'failed' ? 'border-l-red-500' :
  369. 'border-l-gray-500'
  370. }
  371. ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
  372. ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
  373. ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
  374. ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
  375. ${isMobileSelectable ? 'sm:cursor-default' : ''}
  376. `}
  377. onClick={isMobileSelectable ? () => {
  378. if (window.innerWidth < 640) onToggleSelect();
  379. } : undefined}
  380. >
  381. {/* Mobile selected left accent bar */}
  382. {isMobileSelectable && isSelected && (
  383. <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
  384. )}
  385. <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
  386. {/* Mobile selection indicator — left accent bar only, no tick */}
  387. {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
  388. {isPending && onToggleSelect && (
  389. <button
  390. onClick={(e) => {
  391. e.stopPropagation();
  392. onToggleSelect();
  393. }}
  394. className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
  395. isSelected
  396. ? 'bg-bambu-green border-bambu-green text-white'
  397. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  398. }`}
  399. >
  400. {isSelected && <Check className="w-4 h-4" />}
  401. </button>
  402. )}
  403. {/* Drag handle or position number - hidden on mobile */}
  404. {isPending ? (
  405. <div
  406. {...attributes}
  407. {...listeners}
  408. className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
  409. >
  410. <GripVertical className="w-4 h-4 text-bambu-gray" />
  411. </div>
  412. ) : position !== undefined ? (
  413. <div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0">
  414. #{position}
  415. </div>
  416. ) : (
  417. <div className="hidden sm:block w-8 shrink-0" />
  418. )}
  419. {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
  420. <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  421. {item.archive_thumbnail ? (
  422. <img
  423. src={
  424. item.plate_id != null
  425. ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
  426. : api.getArchiveThumbnail(item.archive_id!)
  427. }
  428. alt=""
  429. className="w-full h-full object-cover"
  430. />
  431. ) : item.library_file_thumbnail ? (
  432. <img
  433. src={
  434. item.plate_id != null
  435. ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
  436. : api.getLibraryFileThumbnailUrl(item.library_file_id!)
  437. }
  438. alt=""
  439. className="w-full h-full object-cover"
  440. />
  441. ) : (
  442. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  443. <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
  444. </div>
  445. )}
  446. </div>
  447. {/* Info */}
  448. <div className="flex-1 min-w-0">
  449. <div className="flex items-center gap-2 mb-1">
  450. <p className="text-sm sm:text-base text-white font-medium truncate">
  451. {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
  452. {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
  453. </p>
  454. {item.archive_id ? (
  455. <Link
  456. to={`/archives?highlight=${item.archive_id}`}
  457. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  458. title={t('queue.viewArchive')}
  459. >
  460. <ExternalLink className="w-3.5 h-3.5" />
  461. </Link>
  462. ) : item.library_file_id ? (
  463. <Link
  464. to={`/library?highlight=${item.library_file_id}`}
  465. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  466. title={t('queue.viewInFileManager')}
  467. >
  468. <ExternalLink className="w-3.5 h-3.5" />
  469. </Link>
  470. ) : null}
  471. {item.batch_name && (
  472. <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-500/20 text-cyan-300 rounded border border-cyan-500/30">
  473. {item.batch_name}
  474. </span>
  475. )}
  476. </div>
  477. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
  478. <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-400' : ''}`}>
  479. <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  480. <span className="truncate max-w-[120px] sm:max-w-none">
  481. {item.target_model && !item.printer_id
  482. ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
  483. : item.printer_id === null
  484. ? t('queue.filter.unassigned')
  485. : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
  486. </span>
  487. </span>
  488. {item.print_time_seconds && (
  489. <span className="flex items-center gap-1 sm:gap-1.5">
  490. <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  491. {formatDuration(item.print_time_seconds)}
  492. </span>
  493. )}
  494. {item.filament_used_grams && (
  495. <span className="flex items-center gap-1 sm:gap-1.5">
  496. <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  497. {formatWeight(item.filament_used_grams)}
  498. </span>
  499. )}
  500. {(() => {
  501. // Build plate badge so the user knows which plate to mount before
  502. // walking to the printer (#1281). Hidden when the 3MF doesn't
  503. // carry curr_bed_type or the slicer used an unknown label.
  504. const bed = getBedTypeInfo(item.bed_type);
  505. if (!bed) return null;
  506. return (
  507. <span className="flex items-center gap-1 sm:gap-1.5" title={bed.label}>
  508. <img src={bed.icon} alt="" className="w-3.5 h-3.5 sm:w-4 sm:h-4 object-contain" />
  509. <span className="truncate max-w-[120px]">{bed.label}</span>
  510. </span>
  511. );
  512. })()}
  513. {item.created_by_username && (
  514. <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
  515. <User className="w-3.5 h-3.5" />
  516. {item.created_by_username}
  517. </span>
  518. )}
  519. {isPending && !item.manual_start && (
  520. <span className="flex items-center gap-1.5">
  521. <Clock className="w-3.5 h-3.5" />
  522. {item.scheduled_time
  523. ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
  524. ? t?.('queue.time.overdue') ?? 'Overdue'
  525. : formatRelativeTime(item.scheduled_time, timeFormat, t))
  526. : t?.('queue.time.asap') ?? 'ASAP'}
  527. </span>
  528. )}
  529. </div>
  530. {/* Options badges */}
  531. <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
  532. {item.manual_start && (
  533. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
  534. <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  535. {t('queue.badges.staged')}
  536. </span>
  537. )}
  538. {item.require_previous_success && (
  539. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
  540. {t('queue.badges.requiresPrevious')}
  541. </span>
  542. )}
  543. {item.auto_off_after && (
  544. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
  545. <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  546. {t('queue.badges.autoPowerOff')}
  547. </span>
  548. )}
  549. {item.gcode_injection && (
  550. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20 flex items-center gap-1">
  551. <Code className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  552. {t('queue.badges.gcodeInjection')}
  553. </span>
  554. )}
  555. </div>
  556. {/* Progress bar for printing items - TODO: integrate with WebSocket */}
  557. {isPrinting && status && (() => {
  558. // Gate progress/remaining/layer on printer actually running this print.
  559. // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress
  560. // is stale from the previous print — showing 100% then snapping back to 0%
  561. // once the new print starts. Only trust these fields when state is active.
  562. const isActive = status.state === 'RUNNING' || status.state === 'PAUSE';
  563. const progress = isActive ? (status.progress || 0) : 0;
  564. const remaining = isActive ? status.remaining_time : null;
  565. const layerNum = isActive ? status.layer_num : null;
  566. const totalLayers = isActive ? status.total_layers : null;
  567. return (
  568. <div className="mt-2 sm:mt-3">
  569. <div className="flex items-center justify-between text-xs sm:text-sm">
  570. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
  571. <div
  572. className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
  573. style={{ width: `${progress}%` }}
  574. />
  575. </div>
  576. <span className="text-white">{Math.round(progress)}%</span>
  577. </div>
  578. <div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray">
  579. {remaining != null && remaining > 0 && (
  580. <>
  581. <span className="flex items-center gap-1">
  582. <Clock className="w-3 h-3" />
  583. {formatDuration(remaining * 60)}
  584. </span>
  585. <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
  586. ETA {formatETA(remaining, timeFormat, t)}
  587. </span>
  588. </>
  589. )}
  590. {layerNum != null && totalLayers != null && totalLayers > 0 && (
  591. <span className="flex items-center gap-1">
  592. <Layers className="w-3 h-3" />
  593. {layerNum}/{totalLayers}
  594. </span>
  595. )}
  596. </div>
  597. </div>
  598. );
  599. })()}
  600. {/* Waiting reason for model-based assignments */}
  601. {item.waiting_reason && item.status === 'pending' && (
  602. <p className="text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
  603. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  604. <span>{item.waiting_reason}</span>
  605. </p>
  606. )}
  607. {/* Filament-short flag from the dispatch pre-flight (#1496). */}
  608. {item.filament_short && item.status === 'pending' && (
  609. <p
  610. className="text-[10px] sm:text-xs text-yellow-400 mt-1.5 sm:mt-2 flex items-start gap-1"
  611. title={t('queue.filamentShort.rowTooltip')}
  612. >
  613. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  614. <span>{t('queue.filamentShort.rowBadge')}</span>
  615. </p>
  616. )}
  617. {/* Error message */}
  618. {item.error_message && (
  619. <p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
  620. <AlertCircle className="w-3 h-3" />
  621. {item.error_message}
  622. </p>
  623. )}
  624. </div>
  625. {/* Status badge + Actions */}
  626. <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
  627. <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
  628. <div className="flex items-center gap-0.5 sm:gap-1">
  629. {isPrinting && (
  630. <Button
  631. variant="ghost"
  632. size="sm"
  633. onClick={onStop}
  634. disabled={!hasPermission('printers:control')}
  635. title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
  636. className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
  637. >
  638. <StopCircle className="w-4 h-4" />
  639. </Button>
  640. )}
  641. {isPending && (
  642. <>
  643. {item.manual_start && (
  644. <Button
  645. variant="ghost"
  646. size="sm"
  647. onClick={onStart}
  648. disabled={!hasPermission('printers:control')}
  649. title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
  650. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
  651. >
  652. <Play className="w-4 h-4" />
  653. </Button>
  654. )}
  655. <Button
  656. variant="ghost"
  657. size="sm"
  658. onClick={onEdit}
  659. disabled={!canModify('queue', 'update', item.created_by_id)}
  660. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
  661. className="p-1.5 sm:p-2"
  662. >
  663. <Pencil className="w-4 h-4" />
  664. </Button>
  665. <Button
  666. variant="ghost"
  667. size="sm"
  668. onClick={onCancel}
  669. disabled={!canModify('queue', 'delete', item.created_by_id)}
  670. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
  671. className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
  672. >
  673. <X className="w-4 h-4" />
  674. </Button>
  675. </>
  676. )}
  677. {isHistory && (
  678. <>
  679. <Button
  680. variant="ghost"
  681. size="sm"
  682. onClick={onRequeue}
  683. disabled={!hasPermission('queue:create')}
  684. title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
  685. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
  686. >
  687. <RefreshCw className="w-4 h-4" />
  688. </Button>
  689. <Button
  690. variant="ghost"
  691. size="sm"
  692. onClick={onRemove}
  693. disabled={!canModify('queue', 'delete', item.created_by_id)}
  694. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
  695. className="p-1.5 sm:p-2"
  696. >
  697. <Trash2 className="w-4 h-4" />
  698. </Button>
  699. </>
  700. )}
  701. </div>
  702. </div>
  703. </div>
  704. </div>
  705. );
  706. }
  707. type QueueRow =
  708. | { kind: 'item'; item: PrintQueueItem }
  709. | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
  710. interface QueueRowRenderProps {
  711. row: QueueRow;
  712. collapsed: boolean;
  713. onToggleBatch?: () => void;
  714. onUngroup?: () => void;
  715. setEditItem: (item: PrintQueueItem) => void;
  716. setConfirmAction: (a: { type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem }) => void;
  717. startMutation: { mutate: (vars: { id: number; skipFilamentCheck?: boolean }) => void };
  718. selectedItems: number[];
  719. handleToggleSelect: (id: number) => void;
  720. timeFormat: TimeFormat;
  721. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  722. hasPermission: (p: any) => boolean;
  723. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  724. canModify: (resource: any, action: any, createdById?: number | null) => boolean;
  725. t: (key: string, options?: Record<string, unknown>) => string;
  726. aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
  727. }
  728. /** Renders either a single item or a collapsible batch group containing N
  729. * sibling items. The batch parent shows aggregate stats; children render
  730. * with the existing SortableQueueItem (only draggable inside the batch). */
  731. function QueueRowRender({
  732. row,
  733. collapsed,
  734. onToggleBatch,
  735. onUngroup,
  736. setEditItem,
  737. setConfirmAction,
  738. startMutation,
  739. selectedItems,
  740. handleToggleSelect,
  741. timeFormat,
  742. hasPermission,
  743. canModify,
  744. t,
  745. aggregateForRows,
  746. }: QueueRowRenderProps) {
  747. if (row.kind === 'item') {
  748. return (
  749. <SortableQueueItem
  750. item={row.item}
  751. onEdit={() => setEditItem(row.item)}
  752. onCancel={() => setConfirmAction({ type: 'cancel', item: row.item })}
  753. onRemove={() => {}}
  754. onStop={() => {}}
  755. onRequeue={() => {}}
  756. onStart={() => startMutation.mutate({ id: row.item.id })}
  757. timeFormat={timeFormat}
  758. isSelected={selectedItems.includes(row.item.id)}
  759. onToggleSelect={() => handleToggleSelect(row.item.id)}
  760. hasPermission={hasPermission}
  761. canModify={canModify}
  762. t={t}
  763. />
  764. );
  765. }
  766. // Batch group
  767. const agg = aggregateForRows([row]);
  768. const allChildIds = row.items.map((i) => i.id);
  769. const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id));
  770. // Status rollup: worst-of-children (failed > printing > pending).
  771. const childStatuses = new Set(row.items.map((i) => i.status));
  772. // We never put non-pending into a batch grouping but render defensively.
  773. const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed')
  774. ? 'failed'
  775. : childStatuses.has('printing')
  776. ? 'printing'
  777. : 'pending';
  778. const pendingChildren = row.items.filter((i) => i.status === 'pending').length;
  779. return (
  780. <div className="bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden">
  781. {/* Parent header */}
  782. <div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
  783. <button
  784. onClick={(e) => {
  785. e.stopPropagation();
  786. allChildIds.forEach((id) => {
  787. if (allSelected && selectedItems.includes(id)) {
  788. handleToggleSelect(id);
  789. } else if (!allSelected && !selectedItems.includes(id)) {
  790. handleToggleSelect(id);
  791. }
  792. });
  793. }}
  794. className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
  795. allSelected
  796. ? 'bg-bambu-green border-bambu-green text-white'
  797. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  798. }`}
  799. title={allSelected ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  800. >
  801. {allSelected && <Check className="w-4 h-4" />}
  802. </button>
  803. <button
  804. onClick={onToggleBatch}
  805. className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
  806. title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
  807. >
  808. {collapsed ? (
  809. <ChevronRight className="w-4 h-4 text-bambu-gray" />
  810. ) : (
  811. <ChevronDown className="w-4 h-4 text-bambu-gray" />
  812. )}
  813. </button>
  814. <div className="w-10 h-10 sm:w-12 sm:h-12 flex-shrink-0 bg-bambu-dark rounded-lg flex items-center justify-center">
  815. {collapsed ? (
  816. <Package className="w-5 h-5 text-cyan-300" />
  817. ) : (
  818. <PackageOpen className="w-5 h-5 text-cyan-300" />
  819. )}
  820. </div>
  821. <div className="flex-1 min-w-0">
  822. <div className="flex items-center gap-2 mb-1">
  823. <p className="text-sm sm:text-base text-white font-medium truncate">{row.batchName}</p>
  824. <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-500/15 text-cyan-300 rounded border border-cyan-500/30">
  825. {t('queue.batch.label', { count: agg.count })}
  826. </span>
  827. </div>
  828. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
  829. {agg.time > 0 && (
  830. <span className="flex items-center gap-1 sm:gap-1.5">
  831. <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  832. {formatDuration(agg.time)}
  833. </span>
  834. )}
  835. {agg.weight > 0 && (
  836. <span className="flex items-center gap-1 sm:gap-1.5">
  837. <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  838. {formatWeight(agg.weight)}
  839. </span>
  840. )}
  841. {pendingChildren > 0 && rollupStatus === 'pending' && (
  842. <span className="flex items-center gap-1 sm:gap-1.5 text-yellow-400">
  843. <Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  844. {t('queue.batch.pendingCount', { count: pendingChildren })}
  845. </span>
  846. )}
  847. </div>
  848. </div>
  849. <div className="flex items-center gap-1 shrink-0">
  850. {onUngroup && (
  851. <Button
  852. variant="ghost"
  853. size="sm"
  854. onClick={onUngroup}
  855. title={t('queue.batch.ungroup')}
  856. className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-500/10 p-1.5 sm:p-2"
  857. >
  858. <Ungroup className="w-4 h-4" />
  859. </Button>
  860. )}
  861. </div>
  862. </div>
  863. {/* Children (only when expanded) */}
  864. {!collapsed && (
  865. <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
  866. {row.items.map((child) => (
  867. <SortableQueueItem
  868. key={child.id}
  869. item={child}
  870. onEdit={() => setEditItem(child)}
  871. onCancel={() => setConfirmAction({ type: 'cancel', item: child })}
  872. onRemove={() => {}}
  873. onStop={() => {}}
  874. onRequeue={() => {}}
  875. onStart={() => startMutation.mutate({ id: child.id })}
  876. timeFormat={timeFormat}
  877. isSelected={selectedItems.includes(child.id)}
  878. onToggleSelect={() => handleToggleSelect(child.id)}
  879. hasPermission={hasPermission}
  880. canModify={canModify}
  881. t={t}
  882. />
  883. ))}
  884. </div>
  885. )}
  886. </div>
  887. );
  888. }
  889. type HistoryRow =
  890. | { kind: 'item'; item: PrintQueueItem }
  891. | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
  892. interface HistorySectionProps {
  893. items: PrintQueueItem[];
  894. collapsed: boolean;
  895. sortBy: 'date' | 'name' | 'printer';
  896. sortAsc: boolean;
  897. onSortByChange: (v: 'date' | 'name' | 'printer') => void;
  898. onSortAscToggle: () => void;
  899. onRemove: (item: PrintQueueItem) => void;
  900. onRequeue: (item: PrintQueueItem) => void;
  901. timeFormat: TimeFormat;
  902. batchCollapsed: Record<number, boolean>;
  903. toggleBatchCollapsed: (id: number) => void;
  904. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  905. hasPermission: (p: any) => boolean;
  906. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  907. canModify: (resource: any, action: any, createdById?: number | null) => boolean;
  908. t: (key: string, options?: Record<string, unknown>) => string;
  909. }
  910. function HistorySection({
  911. items,
  912. sortBy,
  913. sortAsc,
  914. onSortByChange,
  915. onSortAscToggle,
  916. onRemove,
  917. onRequeue,
  918. timeFormat,
  919. batchCollapsed,
  920. toggleBatchCollapsed,
  921. hasPermission,
  922. canModify,
  923. t,
  924. }: HistorySectionProps) {
  925. if (items.length === 0) {
  926. return (
  927. <Card className="p-12 text-center border-dashed">
  928. <ListOrdered className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  929. <h3 className="text-xl font-medium text-white mb-2">{t('queue.history.emptyTitle')}</h3>
  930. <p className="text-bambu-gray max-w-md mx-auto">{t('queue.history.emptyDescription')}</p>
  931. </Card>
  932. );
  933. }
  934. // Group siblings sharing a batch_id into a single collapsible row.
  935. // First-seen order is preserved for batches; items keep their sorted
  936. // position from the parent's sort selector.
  937. const rows: HistoryRow[] = [];
  938. const seenBatches = new Set<number>();
  939. for (const item of items.slice(0, 50)) {
  940. if (item.batch_id != null) {
  941. if (seenBatches.has(item.batch_id)) continue;
  942. seenBatches.add(item.batch_id);
  943. const siblings = items.filter((s) => s.batch_id === item.batch_id);
  944. rows.push({
  945. kind: 'batch',
  946. batchId: item.batch_id,
  947. batchName: item.batch_name || t('queue.batch.defaultName'),
  948. items: siblings,
  949. });
  950. } else {
  951. rows.push({ kind: 'item', item });
  952. }
  953. }
  954. return (
  955. <div>
  956. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  957. <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
  958. {t('queue.sections.history')}
  959. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  960. ({t('queue.itemCount', { count: items.length })})
  961. </span>
  962. </h2>
  963. <div className="flex items-center gap-2">
  964. <select
  965. className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  966. value={sortBy}
  967. onChange={(e) => onSortByChange(e.target.value as 'date' | 'name' | 'printer')}
  968. >
  969. <option value="date">{t('queue.sort.byDate')}</option>
  970. <option value="name">{t('queue.sort.byName')}</option>
  971. <option value="printer">{t('queue.sort.byPrinter')}</option>
  972. </select>
  973. <Button
  974. variant="ghost"
  975. size="sm"
  976. onClick={onSortAscToggle}
  977. title={sortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
  978. className="px-2"
  979. >
  980. {sortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  981. </Button>
  982. </div>
  983. </div>
  984. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
  985. {rows.map((row) => {
  986. if (row.kind === 'item') {
  987. return (
  988. <CompactHistoryRow
  989. key={row.item.id}
  990. item={row.item}
  991. onRemove={() => onRemove(row.item)}
  992. onRequeue={() => onRequeue(row.item)}
  993. timeFormat={timeFormat}
  994. hasPermission={hasPermission}
  995. canModify={canModify}
  996. t={t}
  997. />
  998. );
  999. }
  1000. // Batch group — spans the full grid width so it visually anchors
  1001. // its children below it. The children themselves render in the
  1002. // same responsive grid pattern inside the expanded body.
  1003. const collapsed = batchCollapsed[row.batchId] ?? true;
  1004. const completed = row.items.filter((i) => i.status === 'completed').length;
  1005. const failed = row.items.filter((i) => i.status === 'failed').length;
  1006. const skipped = row.items.filter((i) => i.status === 'skipped').length;
  1007. const cancelled = row.items.filter((i) => i.status === 'cancelled').length;
  1008. const latest = row.items
  1009. .map((i) => i.completed_at || i.created_at)
  1010. .filter((v): v is string => !!v)
  1011. .sort()
  1012. .at(-1);
  1013. return (
  1014. <div
  1015. key={`batch-${row.batchId}`}
  1016. className="md:col-span-2 xl:col-span-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] border-l-cyan-400"
  1017. >
  1018. <button
  1019. onClick={() => toggleBatchCollapsed(row.batchId)}
  1020. className="w-full flex items-center gap-2 sm:gap-3 px-3 py-2 hover:bg-bambu-dark/30 transition-colors text-left"
  1021. title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
  1022. >
  1023. {collapsed ? (
  1024. <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
  1025. ) : (
  1026. <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
  1027. )}
  1028. {collapsed ? (
  1029. <Package className="w-5 h-5 text-cyan-300 shrink-0" />
  1030. ) : (
  1031. <PackageOpen className="w-5 h-5 text-cyan-300 shrink-0" />
  1032. )}
  1033. <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
  1034. {row.batchName}
  1035. </span>
  1036. <div className="flex items-center gap-2 text-xs text-bambu-gray shrink-0">
  1037. {completed > 0 && (
  1038. <span className="flex items-center gap-1 text-emerald-400">
  1039. <CheckCircle className="w-3 h-3" />
  1040. {completed}
  1041. </span>
  1042. )}
  1043. {failed > 0 && (
  1044. <span className="flex items-center gap-1 text-red-400">
  1045. <XCircle className="w-3 h-3" />
  1046. {failed}
  1047. </span>
  1048. )}
  1049. {skipped > 0 && (
  1050. <span className="flex items-center gap-1 text-orange-400">
  1051. <SkipForward className="w-3 h-3" />
  1052. {skipped}
  1053. </span>
  1054. )}
  1055. {cancelled > 0 && (
  1056. <span className="flex items-center gap-1 text-gray-400">
  1057. <Ban className="w-3 h-3" />
  1058. {cancelled}
  1059. </span>
  1060. )}
  1061. <span title={latest ?? undefined}>
  1062. {latest ? formatRelativeTime(latest, timeFormat, t) : null}
  1063. </span>
  1064. </div>
  1065. </button>
  1066. {!collapsed && (
  1067. <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
  1068. {row.items.map((child) => (
  1069. <CompactHistoryRow
  1070. key={child.id}
  1071. item={child}
  1072. onRemove={() => onRemove(child)}
  1073. onRequeue={() => onRequeue(child)}
  1074. timeFormat={timeFormat}
  1075. hasPermission={hasPermission}
  1076. canModify={canModify}
  1077. t={t}
  1078. />
  1079. ))}
  1080. </div>
  1081. )}
  1082. </div>
  1083. );
  1084. })}
  1085. </div>
  1086. </div>
  1087. );
  1088. }
  1089. export function QueuePage() {
  1090. const { t } = useTranslation();
  1091. const queryClient = useQueryClient();
  1092. const { showToast } = useToast();
  1093. const { hasPermission, hasAnyPermission, canModify } = useAuth();
  1094. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  1095. const [filterStatus, setFilterStatus] = useState<string>('');
  1096. const [filterLocation, setFilterLocation] = useState<string>('');
  1097. const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
  1098. const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
  1099. const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
  1100. const [confirmAction, setConfirmAction] = useState<{
  1101. type: 'cancel' | 'remove' | 'stop';
  1102. item: PrintQueueItem;
  1103. } | null>(null);
  1104. const [selectedItems, setSelectedItems] = useState<number[]>([]);
  1105. const [showBulkEditModal, setShowBulkEditModal] = useState(false);
  1106. const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
  1107. const saved = localStorage.getItem('queue.historySortBy');
  1108. return (saved as 'date' | 'name' | 'printer') || 'date';
  1109. });
  1110. const [historySortAsc, setHistorySortAsc] = useState(() => {
  1111. const saved = localStorage.getItem('queue.historySortAsc');
  1112. return saved !== null ? saved === 'true' : false;
  1113. });
  1114. const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
  1115. const saved = localStorage.getItem('queue.pendingSortBy');
  1116. return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
  1117. });
  1118. const [pendingSortAsc, setPendingSortAsc] = useState(() => {
  1119. const saved = localStorage.getItem('queue.pendingSortAsc');
  1120. return saved !== null ? saved === 'true' : true;
  1121. });
  1122. // historyCollapsed legacy state retained only for localStorage migration; the
  1123. // History tab renders unconditionally so this no longer drives the UI.
  1124. // Tabbed page structure: Active queue stays as the main view; History
  1125. // and Timeline split off. Persists per-user via localStorage.
  1126. const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline'>(() => {
  1127. const saved = localStorage.getItem('queue.activeTab');
  1128. if (saved === 'history' || saved === 'timeline') return saved;
  1129. return 'queue';
  1130. });
  1131. // Active-tab layout toggle. "position" = today's flat list; "printer"
  1132. // groups items under per-printer section headers with aggregate stats.
  1133. const [activeLayout, setActiveLayout] = useState<'position' | 'printer'>(() => {
  1134. const saved = localStorage.getItem('queue.activeLayout');
  1135. return saved === 'printer' ? 'printer' : 'position';
  1136. });
  1137. // Per-batch collapse state, keyed by batch_id. Default = collapsed
  1138. // (matches the SimplyPrint/Files convention — show the rollup first).
  1139. const [batchCollapsed, setBatchCollapsed] = useState<Record<number, boolean>>(() => {
  1140. try {
  1141. const saved = localStorage.getItem('queue.batchCollapsed');
  1142. return saved ? JSON.parse(saved) : {};
  1143. } catch {
  1144. return {};
  1145. }
  1146. });
  1147. // Multi-drag bookkeeping for DragOverlay.
  1148. const [activeDragId, setActiveDragId] = useState<number | null>(null);
  1149. // "Group as batch" modal.
  1150. const [groupBatchModal, setGroupBatchModal] = useState(false);
  1151. // Ungroup confirm.
  1152. const [ungroupBatchId, setUngroupBatchId] = useState<number | null>(null);
  1153. // Persist sort settings to localStorage
  1154. useEffect(() => {
  1155. localStorage.setItem('queue.historySortBy', historySortBy);
  1156. }, [historySortBy]);
  1157. useEffect(() => {
  1158. localStorage.setItem('queue.historySortAsc', String(historySortAsc));
  1159. }, [historySortAsc]);
  1160. useEffect(() => {
  1161. localStorage.setItem('queue.pendingSortBy', pendingSortBy);
  1162. }, [pendingSortBy]);
  1163. useEffect(() => {
  1164. localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
  1165. }, [pendingSortAsc]);
  1166. useEffect(() => {
  1167. localStorage.setItem('queue.activeTab', activeTab);
  1168. }, [activeTab]);
  1169. useEffect(() => {
  1170. localStorage.setItem('queue.activeLayout', activeLayout);
  1171. }, [activeLayout]);
  1172. useEffect(() => {
  1173. localStorage.setItem('queue.batchCollapsed', JSON.stringify(batchCollapsed));
  1174. }, [batchCollapsed]);
  1175. const sensors = useSensors(
  1176. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  1177. useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  1178. );
  1179. const { data: settings } = useQuery({
  1180. queryKey: ['settings'],
  1181. queryFn: api.getSettings,
  1182. });
  1183. const timeFormat: TimeFormat = settings?.time_format || 'system';
  1184. const { data: queue, isLoading } = useQuery({
  1185. queryKey: ['queue', filterPrinter, filterStatus],
  1186. queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
  1187. refetchInterval: 5000,
  1188. });
  1189. const { data: printers } = useQuery({
  1190. queryKey: ['printers'],
  1191. queryFn: () => api.getPrinters(),
  1192. });
  1193. const sjfMutation = useMutation({
  1194. mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
  1195. onSuccess: () => {
  1196. queryClient.invalidateQueries({ queryKey: ['settings'] });
  1197. },
  1198. });
  1199. const cancelMutation = useMutation({
  1200. mutationFn: (id: number) => api.cancelQueueItem(id),
  1201. onSuccess: () => {
  1202. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1203. showToast(t('queue.toast.cancelled'));
  1204. },
  1205. onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
  1206. });
  1207. const removeMutation = useMutation({
  1208. mutationFn: (id: number) => api.removeFromQueue(id),
  1209. onSuccess: () => {
  1210. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1211. showToast(t('queue.toast.removed'));
  1212. },
  1213. onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
  1214. });
  1215. const stopMutation = useMutation({
  1216. mutationFn: (id: number) => api.stopQueueItem(id),
  1217. onSuccess: () => {
  1218. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1219. showToast(t('queue.toast.stopped'));
  1220. },
  1221. onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
  1222. });
  1223. // Filament-deficit confirmation state (#1496). When the backend returns
  1224. // 409 with `code=insufficient_filament` we stash the deficit + item id
  1225. // here; the modal at the bottom of the page reads it and the "Print
  1226. // Anyway" path re-issues the start with `skipFilamentCheck=true`.
  1227. const [filamentShortConfirm, setFilamentShortConfirm] = useState<{
  1228. itemId: number;
  1229. deficit: Array<{
  1230. slot_id: number;
  1231. required_grams: number;
  1232. remaining_grams: number | null;
  1233. filament_type?: string | null;
  1234. }>;
  1235. } | null>(null);
  1236. const startMutation = useMutation({
  1237. mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
  1238. api.startQueueItem(id, { skipFilamentCheck }),
  1239. onSuccess: () => {
  1240. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1241. showToast(t('queue.toast.released'));
  1242. setFilamentShortConfirm(null);
  1243. },
  1244. onError: (error: unknown, variables) => {
  1245. if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
  1246. const deficitRaw = (error.detail?.deficit ?? []) as Array<{
  1247. slot_id: number;
  1248. required_grams: number;
  1249. remaining_grams: number | null;
  1250. filament_type?: string | null;
  1251. }>;
  1252. setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
  1253. return;
  1254. }
  1255. showToast(t('queue.toast.startFailed'), 'error');
  1256. },
  1257. });
  1258. const reorderMutation = useMutation({
  1259. mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
  1260. onSuccess: () => {
  1261. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1262. },
  1263. onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
  1264. });
  1265. const clearHistoryMutation = useMutation({
  1266. mutationFn: async () => {
  1267. const historyItems = queue?.filter(i =>
  1268. ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
  1269. ) || [];
  1270. for (const item of historyItems) {
  1271. await api.removeFromQueue(item.id);
  1272. }
  1273. return historyItems.length;
  1274. },
  1275. onSuccess: (count) => {
  1276. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1277. showToast(t('queue.toast.historyCleared', { count }));
  1278. },
  1279. onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
  1280. });
  1281. const bulkUpdateMutation = useMutation({
  1282. mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
  1283. onSuccess: (result) => {
  1284. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1285. setSelectedItems([]);
  1286. setShowBulkEditModal(false);
  1287. showToast(result.message);
  1288. },
  1289. onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
  1290. });
  1291. const bulkCancelMutation = useMutation({
  1292. mutationFn: async (ids: number[]) => {
  1293. for (const id of ids) {
  1294. await api.cancelQueueItem(id);
  1295. }
  1296. return ids.length;
  1297. },
  1298. onSuccess: (count) => {
  1299. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1300. setSelectedItems([]);
  1301. showToast(t('queue.toast.bulkCancelled', { count }));
  1302. },
  1303. onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
  1304. });
  1305. const createBatchMutation = useMutation({
  1306. mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
  1307. onSuccess: (batch) => {
  1308. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1309. setSelectedItems([]);
  1310. setGroupBatchModal(false);
  1311. // New batches start expanded so the user sees what they just grouped.
  1312. setBatchCollapsed((prev) => ({ ...prev, [batch.id]: false }));
  1313. showToast(t('queue.toast.batchCreated', { name: batch.name }));
  1314. },
  1315. onError: () => showToast(t('queue.toast.batchCreateFailed'), 'error'),
  1316. });
  1317. const ungroupBatchMutation = useMutation({
  1318. mutationFn: (id: number) => api.ungroupBatch(id),
  1319. onSuccess: (result) => {
  1320. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1321. setUngroupBatchId(null);
  1322. showToast(t('queue.toast.batchUngrouped', { count: result.ungrouped_count }));
  1323. },
  1324. onError: () => showToast(t('queue.toast.batchUngroupFailed'), 'error'),
  1325. });
  1326. const handleToggleSelect = (id: number) => {
  1327. setSelectedItems(prev =>
  1328. prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
  1329. );
  1330. };
  1331. // Get unique locations from printers for the filter dropdown
  1332. const uniqueLocations = useMemo(() => {
  1333. const locations = new Set<string>();
  1334. printers?.forEach(p => {
  1335. if (p.location) locations.add(p.location);
  1336. });
  1337. // Also include locations from queue items (for model-based assignments)
  1338. queue?.forEach(item => {
  1339. if (item.target_location) locations.add(item.target_location);
  1340. });
  1341. return Array.from(locations).sort();
  1342. }, [printers, queue]);
  1343. // Helper to check if a queue item matches the location filter
  1344. const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
  1345. if (!filterLocation) return true;
  1346. // For model-based assignments, check target_location
  1347. if (item.target_location) return item.target_location === filterLocation;
  1348. // For printer-based assignments, check the printer's location
  1349. if (item.printer_id) {
  1350. const printer = printers?.find(p => p.id === item.printer_id);
  1351. return printer?.location === filterLocation;
  1352. }
  1353. return false;
  1354. }, [filterLocation, printers]);
  1355. const pendingItems = useMemo(() => {
  1356. let items = queue?.filter(i => i.status === 'pending') || [];
  1357. // Apply location filter
  1358. if (filterLocation) {
  1359. items = items.filter(matchesLocationFilter);
  1360. }
  1361. // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
  1362. const getScheduledTime = (item: PrintQueueItem): number => {
  1363. if (!item.scheduled_time) return 0;
  1364. const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;
  1365. // Placeholder dates (> 6 months out) are treated as ASAP
  1366. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  1367. return time > sixMonthsFromNow ? 0 : time;
  1368. };
  1369. // When SJF is enabled, override sort to match scheduler order
  1370. if (settings?.queue_shortest_first) {
  1371. return [...items].sort((a, b) => {
  1372. // Group by printer first (nulls = model-based, grouped by target_model)
  1373. const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);
  1374. const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);
  1375. if (aPrinter !== bPrinter) return aPrinter - bPrinter;
  1376. // Within same printer/model: jumped items first (starvation guard)
  1377. const aJumped = a.been_jumped ? 1 : 0;
  1378. const bJumped = b.been_jumped ? 1 : 0;
  1379. if (aJumped !== bJumped) return bJumped - aJumped;
  1380. // Shortest print time next (nulls last)
  1381. const aTime = a.print_time_seconds ?? Infinity;
  1382. const bTime = b.print_time_seconds ?? Infinity;
  1383. if (aTime !== bTime) return aTime - bTime;
  1384. // Position as tiebreaker
  1385. return a.position - b.position;
  1386. });
  1387. }
  1388. return [...items].sort((a, b) => {
  1389. let cmp: number;
  1390. if (pendingSortBy === 'name') {
  1391. const aName = a.archive_name || a.library_file_name || '';
  1392. const bName = b.archive_name || b.library_file_name || '';
  1393. cmp = aName.localeCompare(bName);
  1394. } else if (pendingSortBy === 'printer') {
  1395. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  1396. } else if (pendingSortBy === 'time') {
  1397. // Sort by scheduled start time (when print will begin)
  1398. cmp = getScheduledTime(a) - getScheduledTime(b);
  1399. } else {
  1400. cmp = a.position - b.position;
  1401. }
  1402. return pendingSortAsc ? cmp : -cmp;
  1403. });
  1404. }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);
  1405. const handleSelectAll = () => {
  1406. const allPendingIds = pendingItems.map(i => i.id);
  1407. if (selectedItems.length === allPendingIds.length) {
  1408. setSelectedItems([]);
  1409. } else {
  1410. setSelectedItems(allPendingIds);
  1411. }
  1412. };
  1413. const activeItems = useMemo(() => {
  1414. let items = queue?.filter(i => i.status === 'printing') || [];
  1415. if (filterLocation) {
  1416. items = items.filter(matchesLocationFilter);
  1417. }
  1418. return items;
  1419. }, [queue, filterLocation, matchesLocationFilter]);
  1420. // Get unique printer IDs from active items to fetch their statuses
  1421. const activePrinterIds = useMemo(() => {
  1422. const ids = new Set<number>();
  1423. activeItems.forEach(item => {
  1424. if (item.printer_id) ids.add(item.printer_id);
  1425. });
  1426. return Array.from(ids);
  1427. }, [activeItems]);
  1428. // Fetch printer statuses for printers with active jobs
  1429. const printerStatusQueries = useQueries({
  1430. queries: activePrinterIds.map(printerId => ({
  1431. queryKey: ['printerStatus', printerId],
  1432. queryFn: () => api.getPrinterStatus(printerId),
  1433. refetchInterval: 5000,
  1434. })),
  1435. });
  1436. // Build a map of printer_id -> state for quick lookup
  1437. const printerStateMap = useMemo(() => {
  1438. const map: Record<number, string | null> = {};
  1439. activePrinterIds.forEach((printerId, index) => {
  1440. const result = printerStatusQueries[index];
  1441. if (result?.data?.state) {
  1442. map[printerId] = result.data.state;
  1443. }
  1444. });
  1445. return map;
  1446. }, [activePrinterIds, printerStatusQueries]);
  1447. // Build a map of printer_id -> full status for timeline view
  1448. const printerStatusMap = useMemo(() => {
  1449. const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};
  1450. activePrinterIds.forEach((printerId, index) => {
  1451. const result = printerStatusQueries[index];
  1452. if (result?.data) {
  1453. map[printerId] = {
  1454. progress: result.data.progress ?? undefined,
  1455. remaining_time: result.data.remaining_time ?? undefined,
  1456. state: result.data.state ?? undefined,
  1457. };
  1458. }
  1459. });
  1460. return map;
  1461. }, [activePrinterIds, printerStatusQueries]);
  1462. const historyItems = useMemo(() => {
  1463. let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
  1464. if (filterLocation) {
  1465. items = items.filter(matchesLocationFilter);
  1466. }
  1467. return [...items].sort((a, b) => {
  1468. let cmp: number;
  1469. if (historySortBy === 'name') {
  1470. const aName = a.archive_name || a.library_file_name || '';
  1471. const bName = b.archive_name || b.library_file_name || '';
  1472. cmp = aName.localeCompare(bName);
  1473. } else if (historySortBy === 'printer') {
  1474. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  1475. } else {
  1476. // Default: by date - most recent first (desc) is the natural order
  1477. cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
  1478. }
  1479. return historySortAsc ? -cmp : cmp;
  1480. });
  1481. }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
  1482. // Calculate total queue time
  1483. const totalQueueTime = useMemo(() => {
  1484. return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
  1485. }, [pendingItems]);
  1486. // Calculate total material weight
  1487. const totalWeight = useMemo(() => {
  1488. return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
  1489. }, [pendingItems]);
  1490. const handleDragStart = (event: DragStartEvent) => {
  1491. setActiveDragId(typeof event.active.id === 'number' ? event.active.id : null);
  1492. };
  1493. const handleDragEnd = (event: DragEndEvent) => {
  1494. const { active, over } = event;
  1495. setActiveDragId(null);
  1496. if (!over || active.id === over.id) return;
  1497. // Multi-drag: when the dragged row is part of a multi-selection, move
  1498. // all selected items as a contiguous block, preserving their original
  1499. // relative order. Otherwise, single-row drag (today's behavior).
  1500. const draggedId = active.id as number;
  1501. const movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
  1502. ? selectedItems.slice().sort((a, b) => {
  1503. const ai = pendingItems.findIndex((i) => i.id === a);
  1504. const bi = pendingItems.findIndex((i) => i.id === b);
  1505. return ai - bi;
  1506. })
  1507. : [draggedId];
  1508. const overIndex = pendingItems.findIndex(i => i.id === over.id);
  1509. if (overIndex === -1) return;
  1510. // Remove the moving items, then re-insert at overIndex (adjusted).
  1511. const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
  1512. let insertAt = remaining.findIndex((i) => i.id === over.id);
  1513. if (insertAt === -1) insertAt = overIndex;
  1514. // If dragging downward across the drop target, insert AFTER it; upward
  1515. // = before. dnd-kit's `over` is the row under the pointer, not the gap.
  1516. const draggedOriginalIndex = pendingItems.findIndex((i) => i.id === draggedId);
  1517. if (draggedOriginalIndex < overIndex) insertAt += 1;
  1518. const reordered = [
  1519. ...remaining.slice(0, insertAt),
  1520. ...movingIds
  1521. .map((id) => pendingItems.find((i) => i.id === id))
  1522. .filter((x): x is PrintQueueItem => !!x),
  1523. ...remaining.slice(insertAt),
  1524. ];
  1525. const updates = reordered.map((item, index) => ({
  1526. id: item.id,
  1527. position: index + 1,
  1528. }));
  1529. reorderMutation.mutate(updates);
  1530. };
  1531. // Group pending items by batch_id. Items with batch_id null render as
  1532. // standalone rows; items sharing a batch_id render as a collapsible
  1533. // group keyed by that id. Items inside a group keep their original
  1534. // relative order from pendingItems.
  1535. const groupedRows = useMemo<QueueRow[]>(() => {
  1536. const rows: QueueRow[] = [];
  1537. const seenBatches = new Set<number>();
  1538. for (const item of pendingItems) {
  1539. if (item.batch_id != null) {
  1540. if (seenBatches.has(item.batch_id)) continue;
  1541. seenBatches.add(item.batch_id);
  1542. const siblings = pendingItems.filter((s) => s.batch_id === item.batch_id);
  1543. rows.push({
  1544. kind: 'batch',
  1545. batchId: item.batch_id,
  1546. batchName: item.batch_name || t('queue.batch.defaultName'),
  1547. items: siblings,
  1548. });
  1549. } else {
  1550. rows.push({ kind: 'item', item });
  1551. }
  1552. }
  1553. return rows;
  1554. }, [pendingItems, t]);
  1555. // SortableContext ID list. Only include IDs whose DOM node actually
  1556. // renders — collapsed batch children are detached from the DOM, so
  1557. // registering them with dnd-kit confuses the collision resolver when
  1558. // dragging past a collapsed batch row. v1 batch parent isn't itself
  1559. // draggable (within-batch reorder only); collapsed batches just act as
  1560. // unmovable obstacles.
  1561. const sortableIds = useMemo<number[]>(() => {
  1562. const ids: number[] = [];
  1563. for (const row of groupedRows) {
  1564. if (row.kind === 'item') {
  1565. ids.push(row.item.id);
  1566. } else {
  1567. const collapsed = batchCollapsed[row.batchId] ?? true;
  1568. if (!collapsed) {
  1569. for (const child of row.items) ids.push(child.id);
  1570. }
  1571. }
  1572. }
  1573. return ids;
  1574. }, [groupedRows, batchCollapsed]);
  1575. // Items already in a batch can't be grouped; "Group as batch" only shows
  1576. // when 2+ ungrouped items are selected.
  1577. const canGroupSelected = useMemo(() => {
  1578. if (selectedItems.length < 2) return false;
  1579. return selectedItems.every((id) => {
  1580. const item = pendingItems.find((p) => p.id === id);
  1581. return item && item.batch_id == null;
  1582. });
  1583. }, [selectedItems, pendingItems]);
  1584. const toggleBatchCollapsed = (id: number) => {
  1585. setBatchCollapsed((prev) => ({ ...prev, [id]: !(prev[id] ?? true) }));
  1586. };
  1587. // Group by printer view. Items are bucketed by printer_id (null = model
  1588. // assignment or unassigned, keyed by target_model or "unassigned").
  1589. type PrinterBucket = {
  1590. key: string;
  1591. printerId: number | null;
  1592. targetModel: string | null;
  1593. label: string;
  1594. isUnassigned: boolean;
  1595. rows: QueueRow[];
  1596. };
  1597. const printerBuckets = useMemo<PrinterBucket[]>(() => {
  1598. const buckets = new Map<string, PrinterBucket>();
  1599. const bucketForItem = (item: PrintQueueItem): { key: string; label: string; printerId: number | null; targetModel: string | null; isUnassigned: boolean } => {
  1600. if (item.printer_id) {
  1601. return {
  1602. key: `printer:${item.printer_id}`,
  1603. label: item.printer_name || `Printer #${item.printer_id}`,
  1604. printerId: item.printer_id,
  1605. targetModel: null,
  1606. isUnassigned: false,
  1607. };
  1608. }
  1609. if (item.target_model) {
  1610. return {
  1611. key: `model:${item.target_model}`,
  1612. label: `${t('queue.filter.any')} ${item.target_model}`,
  1613. printerId: null,
  1614. targetModel: item.target_model,
  1615. isUnassigned: false,
  1616. };
  1617. }
  1618. return {
  1619. key: 'unassigned',
  1620. label: t('queue.filter.unassigned'),
  1621. printerId: null,
  1622. targetModel: null,
  1623. isUnassigned: true,
  1624. };
  1625. };
  1626. for (const row of groupedRows) {
  1627. const representative = row.kind === 'item' ? row.item : row.items[0];
  1628. if (!representative) continue;
  1629. const meta = bucketForItem(representative);
  1630. if (!buckets.has(meta.key)) {
  1631. buckets.set(meta.key, { ...meta, rows: [] });
  1632. }
  1633. buckets.get(meta.key)!.rows.push(row);
  1634. }
  1635. return Array.from(buckets.values()).sort((a, b) => {
  1636. if (a.isUnassigned && !b.isUnassigned) return 1;
  1637. if (!a.isUnassigned && b.isUnassigned) return -1;
  1638. return a.label.localeCompare(b.label);
  1639. });
  1640. }, [groupedRows, t]);
  1641. const aggregateForRows = (rows: QueueRow[]) => {
  1642. let count = 0;
  1643. let time = 0;
  1644. let weight = 0;
  1645. for (const row of rows) {
  1646. const items = row.kind === 'item' ? [row.item] : row.items;
  1647. for (const item of items) {
  1648. count += 1;
  1649. time += item.print_time_seconds || 0;
  1650. weight += item.filament_used_grams || 0;
  1651. }
  1652. }
  1653. return { count, time, weight };
  1654. };
  1655. return (
  1656. <div className="p-4 md:p-8">
  1657. {/* Header */}
  1658. <div className="flex items-center justify-between mb-6">
  1659. <div>
  1660. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1661. <ListOrdered className="w-7 h-7 text-bambu-green" />
  1662. {t('queue.title')}
  1663. </h1>
  1664. <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
  1665. </div>
  1666. </div>
  1667. {/* Tab strip — Active queue is the main view; History and Timeline
  1668. live in their own tabs so the queue page stays focused. */}
  1669. <div className="flex gap-1 border-b border-bambu-dark-tertiary mb-6 overflow-x-auto">
  1670. {([
  1671. { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
  1672. { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
  1673. { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
  1674. ]).map(({ id, label, icon: Icon, count }) => (
  1675. <button
  1676. key={id}
  1677. onClick={() => setActiveTab(id)}
  1678. className={`px-4 py-2.5 text-sm flex items-center gap-2 border-b-2 -mb-px transition-colors whitespace-nowrap ${
  1679. activeTab === id
  1680. ? 'text-white border-bambu-green font-medium'
  1681. : 'text-bambu-gray border-transparent hover:text-white'
  1682. }`}
  1683. >
  1684. <Icon className="w-4 h-4" />
  1685. {label}
  1686. {count !== null && count > 0 && (
  1687. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  1688. activeTab === id ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
  1689. }`}>
  1690. {count}
  1691. </span>
  1692. )}
  1693. </button>
  1694. ))}
  1695. </div>
  1696. {/* Summary Stats */}
  1697. <QueueStatsBar
  1698. activeCount={activeItems.length}
  1699. pendingCount={pendingItems.length}
  1700. totalTime={totalQueueTime}
  1701. totalWeight={totalWeight}
  1702. historyCount={historyItems.length}
  1703. t={t}
  1704. />
  1705. {/* Filters */}
  1706. <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
  1707. <select
  1708. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1709. value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
  1710. onChange={(e) => {
  1711. const val = e.target.value;
  1712. if (val === 'unassigned') setFilterPrinter(-1);
  1713. else if (val === '') setFilterPrinter(null);
  1714. else setFilterPrinter(Number(val));
  1715. }}
  1716. >
  1717. <option value="">{t('queue.filter.allPrinters')}</option>
  1718. <option value="unassigned">{t('queue.filter.unassigned')}</option>
  1719. {printers?.map((p) => (
  1720. <option key={p.id} value={p.id}>{p.name}</option>
  1721. ))}
  1722. </select>
  1723. <select
  1724. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1725. value={filterStatus}
  1726. onChange={(e) => setFilterStatus(e.target.value)}
  1727. >
  1728. <option value="">{t('queue.filter.allStatus')}</option>
  1729. <option value="pending">{t('queue.status.pending')}</option>
  1730. <option value="printing">{t('queue.status.printing')}</option>
  1731. <option value="completed">{t('queue.status.completed')}</option>
  1732. <option value="failed">{t('queue.status.failed')}</option>
  1733. <option value="skipped">{t('queue.status.skipped')}</option>
  1734. <option value="cancelled">{t('queue.status.cancelled')}</option>
  1735. </select>
  1736. {uniqueLocations.length > 0 && (
  1737. <select
  1738. className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
  1739. value={filterLocation}
  1740. onChange={(e) => setFilterLocation(e.target.value)}
  1741. >
  1742. <option value="">{t('queue.filter.allLocations')}</option>
  1743. {uniqueLocations.map((loc) => (
  1744. <option key={loc} value={loc}>{loc}</option>
  1745. ))}
  1746. </select>
  1747. )}
  1748. <div className="hidden sm:block flex-1" />
  1749. {activeTab === 'history' && historyItems.length > 0 && (
  1750. <Button
  1751. className="w-full sm:w-auto"
  1752. variant="secondary"
  1753. size="sm"
  1754. onClick={() => setShowClearHistoryConfirm(true)}
  1755. disabled={!hasPermission('queue:delete_all')}
  1756. title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
  1757. >
  1758. <Trash2 className="w-4 h-4" />
  1759. {t('queue.clearHistory')}
  1760. </Button>
  1761. )}
  1762. </div>
  1763. {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
  1764. Hidden on History/Timeline tabs since they don't apply. */}
  1765. {activeTab === 'queue' && (
  1766. <div className="flex flex-wrap items-center gap-3 mb-6">
  1767. <div className="inline-flex items-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-0.5">
  1768. <button
  1769. className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
  1770. activeLayout === 'position' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  1771. }`}
  1772. onClick={() => setActiveLayout('position')}
  1773. title={t('queue.layout.flatList')}
  1774. >
  1775. <List className="w-3.5 h-3.5" />
  1776. <span className="hidden sm:inline">{t('queue.layout.flatList')}</span>
  1777. </button>
  1778. <button
  1779. className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
  1780. activeLayout === 'printer' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  1781. }`}
  1782. onClick={() => setActiveLayout('printer')}
  1783. title={t('queue.layout.groupByPrinter')}
  1784. >
  1785. <Printer className="w-3.5 h-3.5" />
  1786. <span className="hidden sm:inline">{t('queue.layout.byPrinter')}</span>
  1787. </button>
  1788. </div>
  1789. <button
  1790. onClick={() => {
  1791. const newValue = !(settings?.queue_shortest_first ?? false);
  1792. sjfMutation.mutate(newValue);
  1793. }}
  1794. className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${
  1795. settings?.queue_shortest_first
  1796. ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
  1797. : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  1798. }`}
  1799. title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}
  1800. >
  1801. <Snail className="w-4 h-4" />
  1802. <span className="hidden sm:inline">{t('queue.sjf.label', 'SJF')}</span>
  1803. <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />
  1804. </button>
  1805. </div>
  1806. )}
  1807. {isLoading ? (
  1808. <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
  1809. ) : queue?.length === 0 ? (
  1810. <Card className="p-12 text-center border-dashed">
  1811. <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  1812. <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
  1813. <p className="text-bambu-gray max-w-md mx-auto">
  1814. {t('queue.empty.description')}
  1815. </p>
  1816. </Card>
  1817. ) : activeTab === 'timeline' ? (
  1818. <QueueTimelineView
  1819. queueItems={queue || []}
  1820. printers={printers || []}
  1821. printerStatuses={printerStatusMap}
  1822. onItemClick={(item) => {
  1823. if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {
  1824. setRequeueItem(item);
  1825. } else if (item.status === 'pending') {
  1826. setEditItem(item);
  1827. } else if (item.status === 'printing') {
  1828. setConfirmAction({ type: 'stop', item });
  1829. }
  1830. }}
  1831. t={t}
  1832. />
  1833. ) : activeTab === 'history' ? (
  1834. <HistorySection
  1835. items={historyItems}
  1836. collapsed={false}
  1837. sortBy={historySortBy}
  1838. sortAsc={historySortAsc}
  1839. onSortByChange={setHistorySortBy}
  1840. onSortAscToggle={() => setHistorySortAsc(!historySortAsc)}
  1841. onRemove={(item) => setConfirmAction({ type: 'remove', item })}
  1842. onRequeue={setRequeueItem}
  1843. timeFormat={timeFormat}
  1844. batchCollapsed={batchCollapsed}
  1845. toggleBatchCollapsed={toggleBatchCollapsed}
  1846. hasPermission={hasPermission}
  1847. canModify={canModify}
  1848. t={t}
  1849. />
  1850. ) : (
  1851. <div className="space-y-6 sm:space-y-8">
  1852. {/* Active Prints */}
  1853. {activeItems.length > 0 && (
  1854. <div>
  1855. <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
  1856. <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
  1857. {t('queue.sections.currentlyPrinting')}
  1858. </h2>
  1859. <div className="space-y-2 sm:space-y-3">
  1860. {activeItems.map((item) => (
  1861. <SortableQueueItem
  1862. key={item.id}
  1863. item={item}
  1864. onEdit={() => {}}
  1865. onCancel={() => {}}
  1866. onRemove={() => {}}
  1867. onStop={() => setConfirmAction({ type: 'stop', item })}
  1868. onRequeue={() => {}}
  1869. onStart={() => {}}
  1870. timeFormat={timeFormat}
  1871. hasPermission={hasPermission}
  1872. canModify={canModify}
  1873. printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
  1874. t={t}
  1875. />
  1876. ))}
  1877. </div>
  1878. </div>
  1879. )}
  1880. {/* Pending Queue */}
  1881. {pendingItems.length > 0 && (
  1882. <div>
  1883. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  1884. <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
  1885. <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
  1886. {t('queue.sections.queued')}
  1887. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  1888. ({t('queue.itemCount', { count: pendingItems.length })})
  1889. </span>
  1890. <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
  1891. {t('queue.dragToReorder')}
  1892. </span>
  1893. </h2>
  1894. <div className="flex items-center gap-2">
  1895. <select
  1896. className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1897. value={pendingSortBy}
  1898. onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
  1899. >
  1900. <option value="position">{t('queue.sort.byPosition')}</option>
  1901. <option value="name">{t('queue.sort.byName')}</option>
  1902. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1903. <option value="time">{t('queue.sort.bySchedule')}</option>
  1904. </select>
  1905. <Button
  1906. variant="ghost"
  1907. size="sm"
  1908. onClick={() => setPendingSortAsc(!pendingSortAsc)}
  1909. title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
  1910. className="px-2"
  1911. >
  1912. {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1913. </Button>
  1914. </div>
  1915. </div>
  1916. {/* Bulk action toolbar (now with "Group as batch") */}
  1917. <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg">
  1918. <Button
  1919. variant="ghost"
  1920. size="sm"
  1921. onClick={handleSelectAll}
  1922. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
  1923. >
  1924. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
  1925. <CheckSquare className="w-4 h-4 text-bambu-green" />
  1926. ) : (
  1927. <Square className="w-4 h-4" />
  1928. )}
  1929. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  1930. </Button>
  1931. {selectedItems.length > 0 && (
  1932. <>
  1933. <span className="text-xs sm:text-sm text-bambu-gray">
  1934. {t('queue.bulkEdit.selected', { count: selectedItems.length })}
  1935. </span>
  1936. <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
  1937. {canGroupSelected && (
  1938. <Button
  1939. variant="ghost"
  1940. size="sm"
  1941. onClick={() => setGroupBatchModal(true)}
  1942. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-cyan-300 hover:text-cyan-200"
  1943. title={t('queue.batch.groupAsBatch')}
  1944. >
  1945. <Package className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  1946. <span className="hidden sm:inline">{t('queue.batch.groupAsBatch')}</span>
  1947. </Button>
  1948. )}
  1949. <Button
  1950. variant="ghost"
  1951. size="sm"
  1952. onClick={() => setShowBulkEditModal(true)}
  1953. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
  1954. disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
  1955. title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
  1956. >
  1957. <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  1958. <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
  1959. </Button>
  1960. <Button
  1961. variant="ghost"
  1962. size="sm"
  1963. onClick={() => bulkCancelMutation.mutate(selectedItems)}
  1964. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300"
  1965. disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
  1966. title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
  1967. >
  1968. <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  1969. <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
  1970. </Button>
  1971. </>
  1972. )}
  1973. </div>
  1974. <DndContext
  1975. sensors={sensors}
  1976. collisionDetection={closestCenter}
  1977. onDragStart={handleDragStart}
  1978. onDragEnd={handleDragEnd}
  1979. >
  1980. <SortableContext
  1981. items={sortableIds}
  1982. strategy={verticalListSortingStrategy}
  1983. >
  1984. {activeLayout === 'position' ? (
  1985. <div className="space-y-2 sm:space-y-3">
  1986. {groupedRows.map((row) => (
  1987. <QueueRowRender
  1988. key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
  1989. row={row}
  1990. collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
  1991. onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
  1992. onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
  1993. setEditItem={setEditItem}
  1994. setConfirmAction={setConfirmAction}
  1995. startMutation={startMutation}
  1996. selectedItems={selectedItems}
  1997. handleToggleSelect={handleToggleSelect}
  1998. timeFormat={timeFormat}
  1999. hasPermission={hasPermission}
  2000. canModify={canModify}
  2001. t={t}
  2002. aggregateForRows={aggregateForRows}
  2003. />
  2004. ))}
  2005. </div>
  2006. ) : (
  2007. <div className="space-y-4">
  2008. {printerBuckets.map((bucket) => {
  2009. const agg = aggregateForRows(bucket.rows);
  2010. return (
  2011. <div key={bucket.key}>
  2012. <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-t-lg">
  2013. <Printer className={`w-4 h-4 ${bucket.isUnassigned ? 'text-orange-400' : bucket.targetModel ? 'text-blue-400' : 'text-bambu-green'}`} />
  2014. <span className="font-semibold text-white text-sm">{bucket.label}</span>
  2015. <span className="text-xs text-bambu-gray flex flex-wrap gap-x-3">
  2016. <span>{t('queue.itemCount', { count: agg.count })}</span>
  2017. {agg.time > 0 && <span>{formatDuration(agg.time)}</span>}
  2018. {agg.weight > 0 && <span>{formatWeight(agg.weight)}</span>}
  2019. </span>
  2020. </div>
  2021. <div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
  2022. {bucket.rows.map((row) => (
  2023. <QueueRowRender
  2024. key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
  2025. row={row}
  2026. collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
  2027. onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
  2028. onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
  2029. setEditItem={setEditItem}
  2030. setConfirmAction={setConfirmAction}
  2031. startMutation={startMutation}
  2032. selectedItems={selectedItems}
  2033. handleToggleSelect={handleToggleSelect}
  2034. timeFormat={timeFormat}
  2035. hasPermission={hasPermission}
  2036. canModify={canModify}
  2037. t={t}
  2038. aggregateForRows={aggregateForRows}
  2039. />
  2040. ))}
  2041. </div>
  2042. </div>
  2043. );
  2044. })}
  2045. </div>
  2046. )}
  2047. </SortableContext>
  2048. <DragOverlay>
  2049. {activeDragId !== null && selectedItems.includes(activeDragId) && selectedItems.length > 1 ? (
  2050. <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
  2051. <Package className="w-4 h-4 text-cyan-300" />
  2052. <span className="text-sm text-white font-medium">
  2053. {t('queue.dragGhost.multiCount', { count: selectedItems.length })}
  2054. </span>
  2055. </div>
  2056. ) : null}
  2057. </DragOverlay>
  2058. </DndContext>
  2059. </div>
  2060. )}
  2061. </div>
  2062. )}
  2063. {/* Edit Modal */}
  2064. {editItem && (
  2065. <PrintModal
  2066. mode="edit-queue-item"
  2067. archiveId={editItem.archive_id ?? undefined}
  2068. libraryFileId={editItem.library_file_id ?? undefined}
  2069. archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
  2070. queueItem={editItem}
  2071. onClose={() => setEditItem(null)}
  2072. />
  2073. )}
  2074. {/* Re-queue Modal */}
  2075. {requeueItem && (
  2076. <PrintModal
  2077. mode="add-to-queue"
  2078. archiveId={requeueItem.archive_id ?? undefined}
  2079. libraryFileId={requeueItem.library_file_id ?? undefined}
  2080. archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
  2081. onClose={() => setRequeueItem(null)}
  2082. />
  2083. )}
  2084. {/* Confirm Action Modal */}
  2085. {filamentShortConfirm && (
  2086. <ConfirmModal
  2087. title={t('queue.filamentShort.confirmTitle')}
  2088. message={
  2089. t('queue.filamentShort.confirmIntro') + '\n\n' +
  2090. filamentShortConfirm.deficit
  2091. .map((d) =>
  2092. t('queue.filamentShort.lineItem', {
  2093. slot: d.slot_id,
  2094. required: Math.round(d.required_grams),
  2095. remaining:
  2096. d.remaining_grams == null
  2097. ? t('queue.filamentShort.unknown')
  2098. : Math.round(d.remaining_grams),
  2099. }),
  2100. )
  2101. .join('\n')
  2102. }
  2103. confirmText={t('queue.filamentShort.printAnyway')}
  2104. variant="warning"
  2105. onConfirm={() => {
  2106. startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true });
  2107. }}
  2108. onCancel={() => setFilamentShortConfirm(null)}
  2109. />
  2110. )}
  2111. {confirmAction && (
  2112. <ConfirmModal
  2113. title={
  2114. confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
  2115. confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
  2116. t('queue.confirm.removeTitle')
  2117. }
  2118. message={
  2119. confirmAction.type === 'cancel'
  2120. ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  2121. : confirmAction.type === 'stop'
  2122. ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  2123. : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
  2124. }
  2125. confirmText={
  2126. confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
  2127. confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
  2128. t('common.remove')
  2129. }
  2130. variant="danger"
  2131. onConfirm={() => {
  2132. if (confirmAction.type === 'cancel') {
  2133. cancelMutation.mutate(confirmAction.item.id);
  2134. } else if (confirmAction.type === 'stop') {
  2135. stopMutation.mutate(confirmAction.item.id);
  2136. } else {
  2137. removeMutation.mutate(confirmAction.item.id);
  2138. }
  2139. setConfirmAction(null);
  2140. }}
  2141. onCancel={() => setConfirmAction(null)}
  2142. />
  2143. )}
  2144. {/* Clear History Confirm Modal */}
  2145. {showClearHistoryConfirm && (
  2146. <ConfirmModal
  2147. title={t('queue.confirm.clearHistoryTitle')}
  2148. message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
  2149. confirmText={t('queue.clearHistory')}
  2150. variant="danger"
  2151. onConfirm={() => {
  2152. clearHistoryMutation.mutate();
  2153. setShowClearHistoryConfirm(false);
  2154. }}
  2155. onCancel={() => setShowClearHistoryConfirm(false)}
  2156. />
  2157. )}
  2158. {/* Bulk Edit Modal */}
  2159. {showBulkEditModal && (
  2160. <BulkEditModal
  2161. selectedCount={selectedItems.length}
  2162. printers={printers?.map(p => ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []}
  2163. onSave={(data) => {
  2164. if (Object.keys(data).length > 0) {
  2165. bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
  2166. }
  2167. }}
  2168. onClose={() => setShowBulkEditModal(false)}
  2169. isSaving={bulkUpdateMutation.isPending}
  2170. canControlPrinter={hasPermission('printers:control')}
  2171. t={t}
  2172. />
  2173. )}
  2174. {/* Group as batch modal — name prompt */}
  2175. {groupBatchModal && (
  2176. <GroupBatchModal
  2177. itemCount={selectedItems.length}
  2178. isSaving={createBatchMutation.isPending}
  2179. defaultName={(() => {
  2180. // Suggest a name derived from the first selected item's source.
  2181. const first = pendingItems.find((i) => selectedItems.includes(i.id));
  2182. const raw = first?.archive_name || first?.library_file_name || '';
  2183. const cleaned = raw.replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
  2184. return cleaned ? `${cleaned}` : t('queue.batch.defaultName');
  2185. })()}
  2186. onSave={(name) => createBatchMutation.mutate({ name, item_ids: selectedItems })}
  2187. onClose={() => setGroupBatchModal(false)}
  2188. t={t}
  2189. />
  2190. )}
  2191. {/* Ungroup batch confirm */}
  2192. {ungroupBatchId !== null && (
  2193. <ConfirmModal
  2194. title={t('queue.batch.ungroupConfirmTitle')}
  2195. message={t('queue.batch.ungroupConfirmMessage')}
  2196. confirmText={t('queue.batch.ungroup')}
  2197. variant="warning"
  2198. onConfirm={() => ungroupBatchMutation.mutate(ungroupBatchId)}
  2199. onCancel={() => setUngroupBatchId(null)}
  2200. />
  2201. )}
  2202. </div>
  2203. );
  2204. }
  2205. interface GroupBatchModalProps {
  2206. itemCount: number;
  2207. defaultName: string;
  2208. isSaving: boolean;
  2209. onSave: (name: string) => void;
  2210. onClose: () => void;
  2211. t: (key: string, options?: Record<string, unknown>) => string;
  2212. }
  2213. function GroupBatchModal({ itemCount, defaultName, isSaving, onSave, onClose, t }: GroupBatchModalProps) {
  2214. const [name, setName] = useState(defaultName);
  2215. return (
  2216. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
  2217. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 w-full max-w-md">
  2218. <h3 className="text-lg font-semibold text-white mb-2 flex items-center gap-2">
  2219. <Package className="w-5 h-5 text-cyan-300" />
  2220. {t('queue.batch.groupAsBatch')}
  2221. </h3>
  2222. <p className="text-sm text-bambu-gray mb-4">
  2223. {t('queue.batch.groupAsBatchDescription', { count: itemCount })}
  2224. </p>
  2225. <label className="block text-sm font-medium text-white mb-2">
  2226. {t('queue.batch.nameLabel')}
  2227. </label>
  2228. <input
  2229. type="text"
  2230. autoFocus
  2231. value={name}
  2232. onChange={(e) => setName(e.target.value)}
  2233. placeholder={t('queue.batch.namePlaceholder')}
  2234. maxLength={120}
  2235. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none mb-5"
  2236. />
  2237. <div className="flex justify-end gap-2">
  2238. <Button variant="ghost" size="sm" onClick={onClose} disabled={isSaving}>
  2239. {t('common.cancel')}
  2240. </Button>
  2241. <Button
  2242. variant="primary"
  2243. size="sm"
  2244. onClick={() => {
  2245. const trimmed = name.trim();
  2246. if (trimmed) onSave(trimmed);
  2247. }}
  2248. disabled={isSaving || !name.trim()}
  2249. >
  2250. {isSaving ? t('common.saving') : t('queue.batch.create')}
  2251. </Button>
  2252. </div>
  2253. </div>
  2254. </div>
  2255. );
  2256. }