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