QueuePage.tsx 127 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001
  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. ChevronUp,
  54. List,
  55. GanttChart,
  56. Code,
  57. Snail,
  58. Package,
  59. PackageOpen,
  60. Ungroup,
  61. Ban,
  62. PlayCircle,
  63. Workflow,
  64. } from 'lucide-react';
  65. import { api, ApiError } from '../api/client';
  66. import { PipelineRunsView } from './PipelineRunsPage';
  67. import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
  68. import { getBedTypeInfo } from '../utils/bedType';
  69. import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
  70. import { Card } from '../components/Card';
  71. import { Button } from '../components/Button';
  72. import { ConfirmModal } from '../components/ConfirmModal';
  73. import { PrintModal } from '../components/PrintModal';
  74. import { useToast } from '../contexts/ToastContext';
  75. import { useAuth } from '../contexts/AuthContext';
  76. import { QueueStatsBar } from '../components/QueueStatsBar';
  77. import { CompactHistoryRow } from '../components/CompactHistoryRow';
  78. import { QueueTimelineView } from '../components/QueueTimelineView';
  79. function formatWeight(g: number, useKg = false): string {
  80. if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
  81. return `${Math.round(g)}g`;
  82. }
  83. function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
  84. // Special case: pending with waiting_reason shows as "Waiting"
  85. if (status === 'pending' && waitingReason) {
  86. return (
  87. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-700 dark:text-purple-400 bg-purple-50 dark:bg-purple-400/10 border-purple-200 dark:border-purple-400/20">
  88. <Clock className="w-3.5 h-3.5" />
  89. {t('queue.status.waiting')}
  90. </span>
  91. );
  92. }
  93. // Special case: printing but printer is paused
  94. if (status === 'printing' && printerState === 'PAUSE') {
  95. return (
  96. <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-400/10 border-yellow-200 dark:border-yellow-400/20">
  97. <Pause className="w-3.5 h-3.5" />
  98. {t('queue.status.paused')}
  99. </span>
  100. );
  101. }
  102. const config = {
  103. pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
  104. printing: { icon: Play, color: 'text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 border-blue-200 dark:border-blue-400/20', label: t('queue.status.printing') },
  105. completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
  106. failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
  107. skipped: { icon: SkipForward, color: 'text-orange-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10 border-orange-200 dark:border-orange-400/20', label: t('queue.status.skipped') },
  108. cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
  109. };
  110. const { icon: Icon, color, label } = config[status];
  111. return (
  112. <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
  113. <Icon className="w-3.5 h-3.5" />
  114. {label}
  115. </span>
  116. );
  117. }
  118. // Bulk edit modal for multiple queue items
  119. function BulkEditModal({
  120. selectedCount,
  121. printers,
  122. onSave,
  123. onClose,
  124. isSaving,
  125. canControlPrinter,
  126. t,
  127. }: {
  128. selectedCount: number;
  129. printers: { id: number; name: string; nozzle_count?: number }[];
  130. onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
  131. onClose: () => void;
  132. isSaving: boolean;
  133. canControlPrinter: boolean;
  134. t: (key: string, options?: Record<string, unknown>) => string;
  135. }) {
  136. const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
  137. const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
  138. const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
  139. const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
  140. const [bedLevelling, setBedLevelling] = useState<CalibrationMode | 'unchanged'>('unchanged');
  141. const [flowCali, setFlowCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
  142. const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
  143. const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
  144. const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
  145. const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
  146. const [nozzleOffsetCali, setNozzleOffsetCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
  147. // Show the dual-nozzle-only toggle when the user has at least one
  148. // dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle
  149. // queues never see it — the MQTT layer ignores the field anyway.
  150. const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2);
  151. const handleSave = () => {
  152. const data: Partial<PrintQueueBulkUpdate> = {};
  153. if (printerId !== 'unchanged') data.printer_id = printerId;
  154. if (manualStart !== 'unchanged') data.manual_start = manualStart;
  155. if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
  156. if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
  157. if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
  158. if (flowCali !== 'unchanged') data.flow_cali = flowCali;
  159. if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
  160. if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
  161. if (timelapse !== 'unchanged') data.timelapse = timelapse;
  162. if (useAms !== 'unchanged') data.use_ams = useAms;
  163. if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
  164. onSave(data);
  165. };
  166. const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
  167. requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
  168. vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
  169. nozzleOffsetCali !== 'unchanged';
  170. return (
  171. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
  172. <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
  173. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  174. <h2 className="text-lg font-semibold text-white">
  175. {t('queue.bulkEdit.title', { count: selectedCount })}
  176. </h2>
  177. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
  178. <X className="w-5 h-5 text-bambu-gray" />
  179. </button>
  180. </div>
  181. <div className="p-4 space-y-4">
  182. <p className="text-sm text-bambu-gray">
  183. {t('queue.bulkEdit.description')}
  184. </p>
  185. {/* Printer Assignment */}
  186. <div>
  187. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
  188. <select
  189. value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
  190. onChange={(e) => {
  191. const val = e.target.value;
  192. if (val === 'unchanged') setPrinterId('unchanged');
  193. else if (val === 'null') setPrinterId(null);
  194. else setPrinterId(Number(val));
  195. }}
  196. 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"
  197. >
  198. <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
  199. <option value="null">{t('queue.filter.unassigned')}</option>
  200. {printers.map(p => (
  201. <option key={p.id} value={p.id}>{p.name}</option>
  202. ))}
  203. </select>
  204. </div>
  205. {/* Queue Options */}
  206. <div>
  207. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
  208. <div className="space-y-2">
  209. <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
  210. <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />
  211. <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
  212. </div>
  213. </div>
  214. {/* Print Options */}
  215. <div>
  216. <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
  217. <div className="space-y-2">
  218. <CalibrationModeToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
  219. <CalibrationModeToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
  220. <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
  221. <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
  222. <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
  223. <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
  224. {hasDualNozzlePrinter && (
  225. <CalibrationModeToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
  226. )}
  227. </div>
  228. </div>
  229. </div>
  230. <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  231. <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
  232. <Button
  233. onClick={handleSave}
  234. disabled={!hasChanges || isSaving}
  235. >
  236. {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
  237. </Button>
  238. </div>
  239. </div>
  240. </div>
  241. );
  242. }
  243. // Tri-state toggle for bulk edit (unchanged / on / off)
  244. function TriStateToggle({
  245. label,
  246. value,
  247. onChange,
  248. disabled,
  249. t,
  250. }: {
  251. label: string;
  252. value: boolean | 'unchanged';
  253. onChange: (val: boolean | 'unchanged') => void;
  254. disabled?: boolean;
  255. t: (key: string) => string;
  256. }) {
  257. return (
  258. <div className={`flex items-center justify-between py-1 ${disabled ? 'opacity-50' : ''}`}>
  259. <span className="text-sm text-bambu-gray">{label}</span>
  260. <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
  261. <button
  262. onClick={() => onChange('unchanged')}
  263. disabled={disabled}
  264. 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`}
  265. >
  266. </button>
  267. <button
  268. onClick={() => onChange(false)}
  269. disabled={disabled}
  270. className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
  271. >
  272. {t('common.off')}
  273. </button>
  274. <button
  275. onClick={() => onChange(true)}
  276. disabled={disabled}
  277. 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`}
  278. >
  279. {t('common.on')}
  280. </button>
  281. </div>
  282. </div>
  283. );
  284. }
  285. // Four-state selector for the tri-state calibration options in bulk edit
  286. // (unchanged / off / auto / on). Mirrors TriStateToggle's chrome.
  287. function CalibrationModeToggle({
  288. label,
  289. value,
  290. onChange,
  291. t,
  292. }: {
  293. label: string;
  294. value: CalibrationMode | 'unchanged';
  295. onChange: (val: CalibrationMode | 'unchanged') => void;
  296. t: (key: string) => string;
  297. }) {
  298. const modes: Array<{ key: CalibrationMode | 'unchanged'; label: string; active: string }> = [
  299. { key: 'unchanged', label: '—', active: 'bg-bambu-dark-tertiary text-white' },
  300. { key: 'off', label: t('settings.calibrationMode_off'), active: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' },
  301. { key: 'auto', label: t('settings.calibrationMode_auto'), active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' },
  302. { key: 'on', label: t('settings.calibrationMode_on'), active: 'bg-bambu-green/20 text-bambu-green' },
  303. ];
  304. return (
  305. <div className="flex items-center justify-between py-1">
  306. <span className="text-sm text-bambu-gray">{label}</span>
  307. <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
  308. {modes.map(({ key, label: modeLabel, active }) => (
  309. <button
  310. key={key}
  311. onClick={() => onChange(key)}
  312. className={`px-2 py-1 text-xs rounded ${value === key ? active : 'text-bambu-gray hover:text-white'}`}
  313. >
  314. {modeLabel}
  315. </button>
  316. ))}
  317. </div>
  318. </div>
  319. );
  320. }
  321. // Sortable queue item for drag and drop
  322. function SortableQueueItem({
  323. item,
  324. position,
  325. onEdit,
  326. onCancel,
  327. onRemove,
  328. onStop,
  329. onRequeue,
  330. onStart,
  331. onMoveUp,
  332. onMoveDown,
  333. timeFormat = 'system',
  334. isSelected = false,
  335. onToggleSelect,
  336. hasPermission,
  337. canModify,
  338. printerState,
  339. showEta = false,
  340. etaNow,
  341. t,
  342. }: {
  343. item: PrintQueueItem;
  344. position?: number;
  345. onEdit: () => void;
  346. onCancel: () => void;
  347. onRemove: () => void;
  348. onStop: () => void;
  349. onRequeue: () => void;
  350. onStart: () => void;
  351. // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button
  352. // shown disabled) or reordering isn't available; the desktop drag handle
  353. // is unaffected. Move one step among siblings, then persist via reorder.
  354. onMoveUp?: () => void;
  355. onMoveDown?: () => void;
  356. timeFormat?: TimeFormat;
  357. isSelected?: boolean;
  358. onToggleSelect?: () => void;
  359. hasPermission: (permission: Permission) => boolean;
  360. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  361. printerState?: string | null;
  362. // Whether this item qualifies for an "if started now" ETA (#2740), and the
  363. // instant to measure it from. Both are decided by the page so every row on
  364. // screen quotes the same clock.
  365. showEta?: boolean;
  366. etaNow?: number;
  367. t: (key: string, options?: Record<string, unknown>) => string;
  368. }) {
  369. // Fetch printer status every 30 seconds while printing to monitor progress
  370. const { data: status } = useQuery({
  371. queryKey: ['printerStatus', item.printer_id],
  372. queryFn: () => api.getPrinterStatus(item.printer_id!),
  373. refetchInterval: 30000,
  374. enabled: item.printer_id != null && printerState === 'printing',
  375. });
  376. // Determine if we're printing a library file
  377. const isLibraryFile = !!item.library_file_id && !item.archive_id;
  378. // Fetch archive plate details. Skip when the linked archive has been
  379. // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
  380. // /plates endpoint just 404-storms the queue page.
  381. const { data: archivePlatesData } = useQuery({
  382. queryKey: ['archive-plates', item.archive_id],
  383. queryFn: () => api.getArchivePlates(item.archive_id!),
  384. enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
  385. });
  386. // Fetch library file plate details
  387. const { data: libraryPlatesData } = useQuery({
  388. queryKey: ['library-file-plates', item.library_file_id],
  389. queryFn: () => api.getLibraryFilePlates(item.library_file_id!),
  390. enabled: isLibraryFile && !!item.library_file_id,
  391. });
  392. // Combine plates data from either source
  393. const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
  394. const plates = platesData?.plates ?? [];
  395. const canReorder = hasPermission('queue:reorder');
  396. const {
  397. attributes,
  398. listeners,
  399. setNodeRef,
  400. transform,
  401. transition,
  402. isDragging,
  403. } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
  404. const style = {
  405. transform: CSS.Transform.toString(transform),
  406. transition,
  407. };
  408. const isPrinting = item.status === 'printing';
  409. const isPending = item.status === 'pending';
  410. const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
  411. // This is an "if started now" estimate, not a cumulative queue forecast, so
  412. // it is only shown for items the page determined could actually start now
  413. // (see etaEligibleIds). etaNow is the caller's ticking clock — deriving the
  414. // ETA from it rather than from Date.now() keeps this render deterministic and
  415. // stops the value freezing at first paint.
  416. const queueItemEta =
  417. isPending && showEta && item.print_time_seconds != null && item.print_time_seconds > 0
  418. ? formatETA(item.print_time_seconds / 60, timeFormat, t, etaNow)
  419. : null;
  420. const isMobileSelectable = isPending && onToggleSelect;
  421. return (
  422. <div
  423. ref={setNodeRef}
  424. style={style}
  425. className={`
  426. group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
  427. border-l-[3px] ${
  428. isPrinting ? 'border-l-blue-500' :
  429. isPending ? 'border-l-yellow-500' :
  430. item.status === 'completed' ? 'border-l-emerald-500' :
  431. item.status === 'failed' ? 'border-l-red-500' :
  432. 'border-l-gray-500'
  433. }
  434. ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
  435. ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
  436. ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
  437. ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
  438. ${isMobileSelectable ? 'sm:cursor-default' : ''}
  439. `}
  440. onClick={isMobileSelectable ? () => {
  441. if (window.innerWidth < 640) onToggleSelect();
  442. } : undefined}
  443. >
  444. {/* Mobile selected left accent bar */}
  445. {isMobileSelectable && isSelected && (
  446. <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
  447. )}
  448. <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
  449. {/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on
  450. phones and touch-drag is unreliable there, so pending rows get
  451. tap-to-move up/down controls instead. Shown only below `sm`. */}
  452. {isPending && (onMoveUp || onMoveDown) && (
  453. <div
  454. className="flex sm:hidden flex-col shrink-0 -my-1"
  455. onClick={(e) => e.stopPropagation()}
  456. >
  457. <button
  458. type="button"
  459. onClick={onMoveUp}
  460. disabled={!onMoveUp}
  461. title={t('queue.moveUp')}
  462. aria-label={t('queue.moveUp')}
  463. className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
  464. >
  465. <ChevronUp className="w-5 h-5" />
  466. </button>
  467. <button
  468. type="button"
  469. onClick={onMoveDown}
  470. disabled={!onMoveDown}
  471. title={t('queue.moveDown')}
  472. aria-label={t('queue.moveDown')}
  473. className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
  474. >
  475. <ChevronDown className="w-5 h-5" />
  476. </button>
  477. </div>
  478. )}
  479. {/* Mobile selection indicator — left accent bar only, no tick */}
  480. {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
  481. {isPending && onToggleSelect && (
  482. <button
  483. onClick={(e) => {
  484. e.stopPropagation();
  485. onToggleSelect();
  486. }}
  487. className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
  488. isSelected
  489. ? 'bg-bambu-green border-bambu-green text-white'
  490. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  491. }`}
  492. >
  493. {isSelected && <Check className="w-4 h-4" />}
  494. </button>
  495. )}
  496. {/* Drag handle or position number - hidden on mobile */}
  497. {isPending ? (
  498. <div
  499. {...attributes}
  500. {...listeners}
  501. 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-none shrink-0"
  502. >
  503. <GripVertical className="w-4 h-4 text-bambu-gray" />
  504. </div>
  505. ) : position !== undefined ? (
  506. <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">
  507. #{position}
  508. </div>
  509. ) : (
  510. <div className="hidden sm:block w-8 shrink-0" />
  511. )}
  512. {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
  513. <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
  514. {item.archive_thumbnail ? (
  515. <img
  516. src={
  517. item.plate_id != null
  518. ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
  519. : api.getArchiveThumbnail(item.archive_id!)
  520. }
  521. alt=""
  522. className="w-full h-full object-cover"
  523. />
  524. ) : item.library_file_thumbnail ? (
  525. <img
  526. src={
  527. item.plate_id != null
  528. ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
  529. : api.getLibraryFileThumbnailUrl(item.library_file_id!)
  530. }
  531. alt=""
  532. className="w-full h-full object-cover"
  533. />
  534. ) : (
  535. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  536. <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
  537. </div>
  538. )}
  539. </div>
  540. {/* Info */}
  541. <div className="flex-1 min-w-0">
  542. <div className="flex items-center gap-2 mb-1">
  543. <p className="text-sm sm:text-base text-white font-medium truncate">
  544. {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
  545. {(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 })}`}
  546. </p>
  547. {item.archive_id ? (
  548. <Link
  549. to={`/archives?highlight=${item.archive_id}`}
  550. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  551. title={t('queue.viewArchive')}
  552. >
  553. <ExternalLink className="w-3.5 h-3.5" />
  554. </Link>
  555. ) : item.library_file_id ? (
  556. <Link
  557. to={`/library?highlight=${item.library_file_id}`}
  558. className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
  559. title={t('queue.viewInFileManager')}
  560. >
  561. <ExternalLink className="w-3.5 h-3.5" />
  562. </Link>
  563. ) : null}
  564. {item.batch_name && (
  565. <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 rounded border border-cyan-300 dark:border-cyan-500/30">
  566. {item.batch_name}
  567. </span>
  568. )}
  569. </div>
  570. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
  571. <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-700 dark:text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-700 dark:text-blue-400' : ''}`}>
  572. <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  573. <span className="truncate max-w-[120px] sm:max-w-none">
  574. {item.target_model && !item.printer_id
  575. ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
  576. : item.printer_id === null
  577. ? t('queue.filter.unassigned')
  578. : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
  579. </span>
  580. </span>
  581. {item.print_time_seconds && (
  582. <span className="flex items-center gap-1 sm:gap-1.5">
  583. <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  584. {formatDuration(item.print_time_seconds)}
  585. </span>
  586. )}
  587. {queueItemEta && (
  588. <span
  589. data-testid="queue-item-eta"
  590. className="text-bambu-green font-medium"
  591. title={t('queue.time.etaIfStartedNow')}
  592. >
  593. ETA {queueItemEta}
  594. </span>
  595. )}
  596. {item.filament_used_grams && (
  597. <span className="flex items-center gap-1 sm:gap-1.5">
  598. <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  599. {formatWeight(item.filament_used_grams)}
  600. </span>
  601. )}
  602. {(() => {
  603. // Build plate badge so the user knows which plate to mount before
  604. // walking to the printer (#1281). Hidden when the 3MF doesn't
  605. // carry curr_bed_type or the slicer used an unknown label.
  606. const bed = getBedTypeInfo(item.bed_type);
  607. if (!bed) return null;
  608. return (
  609. <span className="flex items-center gap-1 sm:gap-1.5" title={bed.label}>
  610. <img src={bed.icon} alt="" className="w-3.5 h-3.5 sm:w-4 sm:h-4 object-contain" />
  611. <span className="truncate max-w-[120px]">{bed.label}</span>
  612. </span>
  613. );
  614. })()}
  615. {item.created_by_username && (
  616. <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
  617. <User className="w-3.5 h-3.5" />
  618. {item.created_by_username}
  619. </span>
  620. )}
  621. {isPending && !item.manual_start && (
  622. <span className="flex items-center gap-1.5">
  623. <Clock className="w-3.5 h-3.5" />
  624. {item.scheduled_time
  625. ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
  626. ? t?.('queue.time.overdue') ?? 'Overdue'
  627. : formatRelativeTime(item.scheduled_time, timeFormat, t))
  628. : t?.('queue.time.asap') ?? 'ASAP'}
  629. </span>
  630. )}
  631. </div>
  632. {/* Options badges */}
  633. <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
  634. {item.manual_start && (
  635. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 rounded-full border border-purple-200 dark:border-purple-500/20 flex items-center gap-1">
  636. <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  637. {t('queue.badges.staged')}
  638. </span>
  639. )}
  640. {item.require_previous_success && (
  641. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-50 dark:bg-orange-500/10 text-orange-700 dark:text-orange-400 rounded-full border border-orange-200 dark:border-orange-500/20">
  642. {t('queue.badges.requiresPrevious')}
  643. </span>
  644. )}
  645. {item.auto_off_after && (
  646. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded-full border border-blue-200 dark:border-blue-500/20 flex items-center gap-1">
  647. <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  648. {t('queue.badges.autoPowerOff')}
  649. </span>
  650. )}
  651. {item.gcode_injection && (
  652. <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 rounded-full border border-emerald-200 dark:border-emerald-500/20 flex items-center gap-1">
  653. <Code className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
  654. {t('queue.badges.gcodeInjection')}
  655. </span>
  656. )}
  657. </div>
  658. {/* Progress bar for printing items - TODO: integrate with WebSocket */}
  659. {isPrinting && status && (() => {
  660. // Gate progress/remaining/layer on printer actually running this print.
  661. // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress
  662. // is stale from the previous print — showing 100% then snapping back to 0%
  663. // once the new print starts. Only trust these fields when state is active.
  664. const isActive = status.state === 'RUNNING' || status.state === 'PAUSE';
  665. const progress = isActive ? (status.progress || 0) : 0;
  666. const remaining = isActive ? status.remaining_time : null;
  667. const layerNum = isActive ? status.layer_num : null;
  668. const totalLayers = isActive ? status.total_layers : null;
  669. return (
  670. <div className="mt-2 sm:mt-3">
  671. <div className="flex items-center justify-between text-xs sm:text-sm">
  672. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
  673. <div
  674. className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
  675. style={{ width: `${progress}%` }}
  676. />
  677. </div>
  678. <span className="text-white">{Math.round(progress)}%</span>
  679. </div>
  680. <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">
  681. {remaining != null && remaining > 0 && (
  682. <>
  683. <span className="flex items-center gap-1">
  684. <Clock className="w-3 h-3" />
  685. {formatDuration(remaining * 60)}
  686. </span>
  687. <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
  688. ETA {formatETA(remaining, timeFormat, t)}
  689. </span>
  690. </>
  691. )}
  692. {layerNum != null && totalLayers != null && totalLayers > 0 && (
  693. <span className="flex items-center gap-1">
  694. <Layers className="w-3 h-3" />
  695. {layerNum}/{totalLayers}
  696. </span>
  697. )}
  698. </div>
  699. </div>
  700. );
  701. })()}
  702. {/* Waiting reason for model-based assignments */}
  703. {item.waiting_reason && item.status === 'pending' && (
  704. <p className="text-[10px] sm:text-xs text-purple-700 dark:text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
  705. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  706. <span>{item.waiting_reason}</span>
  707. </p>
  708. )}
  709. {/* Filament-short flag from the dispatch pre-flight (#1496). */}
  710. {item.filament_short && item.status === 'pending' && (
  711. <p
  712. className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-400 mt-1.5 sm:mt-2 flex items-start gap-1"
  713. title={t('queue.filamentShort.rowTooltip')}
  714. >
  715. <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
  716. <span>{t('queue.filamentShort.rowBadge')}</span>
  717. </p>
  718. )}
  719. {/* Archive carries the slicer's own live-resolved AMS-slot pick
  720. (extra_data.slicer_ams_mapping) — reprints of this archive reuse
  721. the exact physical spool instead of re-deriving one. */}
  722. {item.archive_has_slicer_ams_mapping && (
  723. <p
  724. className="text-[10px] sm:text-xs text-green-700 dark:text-green-400 mt-1.5 sm:mt-2 flex items-start gap-1"
  725. title={t('queue.slicerAmsMapping.rowTooltip')}
  726. >
  727. <Check className="w-3 h-3 mt-0.5 flex-shrink-0" />
  728. <span>{t('queue.slicerAmsMapping.rowBadge')}</span>
  729. </p>
  730. )}
  731. {/* Error message */}
  732. {item.error_message && (
  733. <p className="text-[10px] sm:text-xs text-red-700 dark:text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
  734. <AlertCircle className="w-3 h-3" />
  735. {item.error_message}
  736. </p>
  737. )}
  738. </div>
  739. {/* Status badge + Actions */}
  740. <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
  741. <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
  742. <div className="flex items-center gap-0.5 sm:gap-1">
  743. {isPrinting && (
  744. <Button
  745. variant="ghost"
  746. size="sm"
  747. onClick={onStop}
  748. disabled={!canModify('queue', 'update', item.created_by_id)}
  749. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
  750. className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10 p-1.5 sm:p-2"
  751. >
  752. <StopCircle className="w-4 h-4" />
  753. </Button>
  754. )}
  755. {isPending && (
  756. <>
  757. {item.manual_start && (
  758. <Button
  759. variant="ghost"
  760. size="sm"
  761. onClick={onStart}
  762. disabled={!canModify('queue', 'update', item.created_by_id)}
  763. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
  764. className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
  765. >
  766. <Play className="w-4 h-4" />
  767. </Button>
  768. )}
  769. <Button
  770. variant="ghost"
  771. size="sm"
  772. onClick={onEdit}
  773. disabled={!canModify('queue', 'update', item.created_by_id)}
  774. title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
  775. className="p-1.5 sm:p-2"
  776. >
  777. <Pencil className="w-4 h-4" />
  778. </Button>
  779. <Button
  780. variant="ghost"
  781. size="sm"
  782. onClick={onCancel}
  783. disabled={!canModify('queue', 'delete', item.created_by_id)}
  784. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
  785. className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10 p-1.5 sm:p-2"
  786. >
  787. <X className="w-4 h-4" />
  788. </Button>
  789. </>
  790. )}
  791. {isHistory && (
  792. <>
  793. <Button
  794. variant="ghost"
  795. size="sm"
  796. onClick={onRequeue}
  797. disabled={!hasPermission('queue:create')}
  798. title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
  799. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
  800. >
  801. <RefreshCw className="w-4 h-4" />
  802. </Button>
  803. <Button
  804. variant="ghost"
  805. size="sm"
  806. onClick={onRemove}
  807. disabled={!canModify('queue', 'delete', item.created_by_id)}
  808. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
  809. className="p-1.5 sm:p-2"
  810. >
  811. <Trash2 className="w-4 h-4" />
  812. </Button>
  813. </>
  814. )}
  815. </div>
  816. </div>
  817. </div>
  818. </div>
  819. );
  820. }
  821. type QueueRow =
  822. | { kind: 'item'; item: PrintQueueItem }
  823. | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
  824. interface QueueRowRenderProps {
  825. row: QueueRow;
  826. collapsed: boolean;
  827. onToggleBatch?: () => void;
  828. onUngroup?: () => void;
  829. setEditItem: (item: PrintQueueItem) => void;
  830. setConfirmAction: (a: { type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem }) => void;
  831. startMutation: { mutate: (vars: { id: number; skipFilamentCheck?: boolean }) => void };
  832. selectedItems: number[];
  833. handleToggleSelect: (id: number) => void;
  834. timeFormat: TimeFormat;
  835. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  836. hasPermission: (p: any) => boolean;
  837. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  838. canModify: (resource: any, action: any, createdById?: number | null) => boolean;
  839. t: (key: string, options?: Record<string, unknown>) => string;
  840. // Items that qualify for an "if started now" ETA, and the shared clock it is
  841. // measured from (#2740).
  842. etaEligibleIds: Set<number>;
  843. etaNow: number;
  844. aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
  845. // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
  846. // (single item or batch) one step among its siblings; onMoveBlock is the
  847. // low-level primitive SortableBatchRow uses to move a child within the
  848. // batch. All undefined when reordering isn't available (non-manual sort).
  849. onMoveUp?: () => void;
  850. onMoveDown?: () => void;
  851. onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void;
  852. }
  853. /** Renders either a single item or a collapsible batch group containing N
  854. * sibling items. The batch parent shows aggregate stats; children render
  855. * with the existing SortableQueueItem (only draggable inside the batch). */
  856. function QueueRowRender(props: QueueRowRenderProps) {
  857. const {
  858. row,
  859. setEditItem,
  860. setConfirmAction,
  861. startMutation,
  862. selectedItems,
  863. handleToggleSelect,
  864. timeFormat,
  865. hasPermission,
  866. canModify,
  867. t,
  868. etaEligibleIds,
  869. etaNow,
  870. onMoveUp,
  871. onMoveDown,
  872. } = props;
  873. if (row.kind === 'item') {
  874. return (
  875. <SortableQueueItem
  876. item={row.item}
  877. onEdit={() => setEditItem(row.item)}
  878. onCancel={() => setConfirmAction({ type: 'cancel', item: row.item })}
  879. onRemove={() => {}}
  880. onStop={() => {}}
  881. onRequeue={() => {}}
  882. onStart={() => startMutation.mutate({ id: row.item.id })}
  883. onMoveUp={onMoveUp}
  884. onMoveDown={onMoveDown}
  885. timeFormat={timeFormat}
  886. isSelected={selectedItems.includes(row.item.id)}
  887. onToggleSelect={() => handleToggleSelect(row.item.id)}
  888. hasPermission={hasPermission}
  889. canModify={canModify}
  890. showEta={etaEligibleIds.has(row.item.id)}
  891. etaNow={etaNow}
  892. t={t}
  893. />
  894. );
  895. }
  896. return <SortableBatchRow {...props} />;
  897. }
  898. /** Batch parent header registered with dnd-kit so the whole group can be
  899. * reordered as one unit. Drag handle lives in the header itself; children
  900. * remain individually draggable while expanded for within-batch reorder. */
  901. function SortableBatchRow({
  902. row,
  903. collapsed,
  904. onToggleBatch,
  905. onUngroup,
  906. setEditItem,
  907. setConfirmAction,
  908. startMutation,
  909. selectedItems,
  910. handleToggleSelect,
  911. timeFormat,
  912. hasPermission,
  913. canModify,
  914. t,
  915. etaEligibleIds,
  916. etaNow,
  917. aggregateForRows,
  918. onMoveUp,
  919. onMoveDown,
  920. onMoveBlock,
  921. }: QueueRowRenderProps) {
  922. // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
  923. // narrow up-front so the hook below can reference batchId unconditionally.
  924. const batchRow = row as Extract<QueueRow, { kind: 'batch' }>;
  925. const canReorder = hasPermission('queue:reorder');
  926. const {
  927. attributes,
  928. listeners,
  929. setNodeRef,
  930. transform,
  931. transition,
  932. isDragging,
  933. } = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder });
  934. const style = {
  935. transform: CSS.Transform.toString(transform),
  936. transition,
  937. };
  938. const agg = aggregateForRows([batchRow]);
  939. const allChildIds = batchRow.items.map((i) => i.id);
  940. const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id));
  941. // Status rollup: worst-of-children (failed > printing > pending).
  942. const childStatuses = new Set(batchRow.items.map((i) => i.status));
  943. // We never put non-pending into a batch grouping but render defensively.
  944. const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed')
  945. ? 'failed'
  946. : childStatuses.has('printing')
  947. ? 'printing'
  948. : 'pending';
  949. const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length;
  950. return (
  951. <div
  952. ref={setNodeRef}
  953. style={style}
  954. className={`bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden ${
  955. isDragging ? 'opacity-50 scale-[1.01] shadow-xl z-50' : ''
  956. }`}
  957. >
  958. {/* Parent header */}
  959. <div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
  960. {/* Mobile reorder arrows for the whole group (#2667), mirroring the
  961. desktop drag handle which is hidden on phones. */}
  962. {canReorder && (onMoveUp || onMoveDown) && (
  963. <div className="flex sm:hidden flex-col shrink-0 -my-1">
  964. <button
  965. type="button"
  966. onClick={onMoveUp}
  967. disabled={!onMoveUp}
  968. title={t('queue.moveUp')}
  969. aria-label={t('queue.moveUp')}
  970. className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
  971. >
  972. <ChevronUp className="w-5 h-5" />
  973. </button>
  974. <button
  975. type="button"
  976. onClick={onMoveDown}
  977. disabled={!onMoveDown}
  978. title={t('queue.moveDown')}
  979. aria-label={t('queue.moveDown')}
  980. className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
  981. >
  982. <ChevronDown className="w-5 h-5" />
  983. </button>
  984. </div>
  985. )}
  986. <button
  987. onClick={(e) => {
  988. e.stopPropagation();
  989. allChildIds.forEach((id) => {
  990. if (allSelected && selectedItems.includes(id)) {
  991. handleToggleSelect(id);
  992. } else if (!allSelected && !selectedItems.includes(id)) {
  993. handleToggleSelect(id);
  994. }
  995. });
  996. }}
  997. className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
  998. allSelected
  999. ? 'bg-bambu-green border-bambu-green text-white'
  1000. : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
  1001. }`}
  1002. title={allSelected ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  1003. >
  1004. {allSelected && <Check className="w-4 h-4" />}
  1005. </button>
  1006. {canReorder && (
  1007. <div
  1008. {...attributes}
  1009. {...listeners}
  1010. 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-none shrink-0"
  1011. title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
  1012. >
  1013. <GripVertical className="w-4 h-4 text-bambu-gray" />
  1014. </div>
  1015. )}
  1016. <button
  1017. onClick={onToggleBatch}
  1018. className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
  1019. title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
  1020. >
  1021. {collapsed ? (
  1022. <ChevronRight className="w-4 h-4 text-bambu-gray" />
  1023. ) : (
  1024. <ChevronDown className="w-4 h-4 text-bambu-gray" />
  1025. )}
  1026. </button>
  1027. <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">
  1028. {collapsed ? (
  1029. <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
  1030. ) : (
  1031. <PackageOpen className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
  1032. )}
  1033. </div>
  1034. <div className="flex-1 min-w-0">
  1035. <div className="flex items-center gap-2 mb-1">
  1036. <p className="text-sm sm:text-base text-white font-medium truncate">{batchRow.batchName}</p>
  1037. <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-100 dark:bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 rounded border border-cyan-300 dark:border-cyan-500/30">
  1038. {t('queue.batch.label', { count: agg.count })}
  1039. </span>
  1040. </div>
  1041. <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
  1042. {agg.time > 0 && (
  1043. <span className="flex items-center gap-1 sm:gap-1.5">
  1044. <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  1045. {formatDuration(agg.time)}
  1046. </span>
  1047. )}
  1048. {agg.weight > 0 && (
  1049. <span className="flex items-center gap-1 sm:gap-1.5">
  1050. <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  1051. {formatWeight(agg.weight)}
  1052. </span>
  1053. )}
  1054. {pendingChildren > 0 && rollupStatus === 'pending' && (
  1055. <span className="flex items-center gap-1 sm:gap-1.5 text-yellow-700 dark:text-yellow-400">
  1056. <Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
  1057. {t('queue.batch.pendingCount', { count: pendingChildren })}
  1058. </span>
  1059. )}
  1060. </div>
  1061. </div>
  1062. <div className="flex items-center gap-1 shrink-0">
  1063. {onUngroup && (
  1064. <Button
  1065. variant="ghost"
  1066. size="sm"
  1067. onClick={onUngroup}
  1068. title={t('queue.batch.ungroup')}
  1069. className="text-cyan-700 dark:text-cyan-300 hover:text-cyan-900 dark:hover:text-cyan-200 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 p-1.5 sm:p-2"
  1070. >
  1071. <Ungroup className="w-4 h-4" />
  1072. </Button>
  1073. )}
  1074. </div>
  1075. </div>
  1076. {/* Children (only when expanded) */}
  1077. {!collapsed && (
  1078. <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
  1079. {batchRow.items.map((child, ci) => (
  1080. <SortableQueueItem
  1081. key={child.id}
  1082. item={child}
  1083. onEdit={() => setEditItem(child)}
  1084. onCancel={() => setConfirmAction({ type: 'cancel', item: child })}
  1085. onRemove={() => {}}
  1086. onStop={() => {}}
  1087. onRequeue={() => {}}
  1088. onStart={() => startMutation.mutate({ id: child.id })}
  1089. onMoveUp={
  1090. onMoveBlock && ci > 0
  1091. ? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false)
  1092. : undefined
  1093. }
  1094. onMoveDown={
  1095. onMoveBlock && ci < batchRow.items.length - 1
  1096. ? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true)
  1097. : undefined
  1098. }
  1099. timeFormat={timeFormat}
  1100. isSelected={selectedItems.includes(child.id)}
  1101. onToggleSelect={() => handleToggleSelect(child.id)}
  1102. hasPermission={hasPermission}
  1103. canModify={canModify}
  1104. showEta={etaEligibleIds.has(child.id)}
  1105. etaNow={etaNow}
  1106. t={t}
  1107. />
  1108. ))}
  1109. </div>
  1110. )}
  1111. </div>
  1112. );
  1113. }
  1114. type HistoryRow =
  1115. | { kind: 'item'; item: PrintQueueItem }
  1116. | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
  1117. interface HistorySectionProps {
  1118. items: PrintQueueItem[];
  1119. collapsed: boolean;
  1120. visibleCount: number;
  1121. onShowMore: () => void;
  1122. sortBy: 'date' | 'name' | 'printer';
  1123. sortAsc: boolean;
  1124. onSortByChange: (v: 'date' | 'name' | 'printer') => void;
  1125. onSortAscToggle: () => void;
  1126. onRemove: (item: PrintQueueItem) => void;
  1127. onRequeue: (item: PrintQueueItem) => void;
  1128. timeFormat: TimeFormat;
  1129. batchCollapsed: Record<number, boolean>;
  1130. toggleBatchCollapsed: (id: number) => void;
  1131. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  1132. hasPermission: (p: any) => boolean;
  1133. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  1134. canModify: (resource: any, action: any, createdById?: number | null) => boolean;
  1135. t: (key: string, options?: Record<string, unknown>) => string;
  1136. }
  1137. function HistorySection({
  1138. items,
  1139. visibleCount,
  1140. onShowMore,
  1141. sortBy,
  1142. sortAsc,
  1143. onSortByChange,
  1144. onSortAscToggle,
  1145. onRemove,
  1146. onRequeue,
  1147. timeFormat,
  1148. batchCollapsed,
  1149. toggleBatchCollapsed,
  1150. hasPermission,
  1151. canModify,
  1152. t,
  1153. }: HistorySectionProps) {
  1154. if (items.length === 0) {
  1155. return (
  1156. <Card className="p-12 text-center border-dashed">
  1157. <ListOrdered className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  1158. <h3 className="text-xl font-medium text-white mb-2">{t('queue.history.emptyTitle')}</h3>
  1159. <p className="text-bambu-gray max-w-md mx-auto">{t('queue.history.emptyDescription')}</p>
  1160. </Card>
  1161. );
  1162. }
  1163. // Group siblings sharing a batch_id into a single collapsible row.
  1164. // First-seen order is preserved for batches; items keep their sorted
  1165. // position from the parent's sort selector.
  1166. const rows: HistoryRow[] = [];
  1167. const seenBatches = new Set<number>();
  1168. for (const item of items.slice(0, visibleCount)) {
  1169. if (item.batch_id != null) {
  1170. if (seenBatches.has(item.batch_id)) continue;
  1171. seenBatches.add(item.batch_id);
  1172. const siblings = items.filter((s) => s.batch_id === item.batch_id);
  1173. rows.push({
  1174. kind: 'batch',
  1175. batchId: item.batch_id,
  1176. batchName: item.batch_name || t('queue.batch.defaultName'),
  1177. items: siblings,
  1178. });
  1179. } else {
  1180. rows.push({ kind: 'item', item });
  1181. }
  1182. }
  1183. return (
  1184. <div>
  1185. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  1186. <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
  1187. {t('queue.sections.history')}
  1188. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  1189. ({t('queue.itemCount', { count: items.length })})
  1190. </span>
  1191. </h2>
  1192. <div className="flex items-center gap-2">
  1193. <select
  1194. 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"
  1195. value={sortBy}
  1196. onChange={(e) => onSortByChange(e.target.value as 'date' | 'name' | 'printer')}
  1197. >
  1198. <option value="date">{t('queue.sort.byDate')}</option>
  1199. <option value="name">{t('queue.sort.byName')}</option>
  1200. <option value="printer">{t('queue.sort.byPrinter')}</option>
  1201. </select>
  1202. <Button
  1203. variant="ghost"
  1204. size="sm"
  1205. onClick={onSortAscToggle}
  1206. title={sortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
  1207. className="px-2"
  1208. >
  1209. {sortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  1210. </Button>
  1211. </div>
  1212. </div>
  1213. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
  1214. {rows.map((row) => {
  1215. if (row.kind === 'item') {
  1216. return (
  1217. <CompactHistoryRow
  1218. key={row.item.id}
  1219. item={row.item}
  1220. onRemove={() => onRemove(row.item)}
  1221. onRequeue={() => onRequeue(row.item)}
  1222. timeFormat={timeFormat}
  1223. hasPermission={hasPermission}
  1224. canModify={canModify}
  1225. t={t}
  1226. />
  1227. );
  1228. }
  1229. // Batch group — spans the full grid width so it visually anchors
  1230. // its children below it. The children themselves render in the
  1231. // same responsive grid pattern inside the expanded body.
  1232. const collapsed = batchCollapsed[row.batchId] ?? true;
  1233. const completed = row.items.filter((i) => i.status === 'completed').length;
  1234. const failed = row.items.filter((i) => i.status === 'failed').length;
  1235. const skipped = row.items.filter((i) => i.status === 'skipped').length;
  1236. const cancelled = row.items.filter((i) => i.status === 'cancelled').length;
  1237. const latest = row.items
  1238. .map((i) => i.completed_at || i.created_at)
  1239. .filter((v): v is string => !!v)
  1240. .sort()
  1241. .at(-1);
  1242. return (
  1243. <div
  1244. key={`batch-${row.batchId}`}
  1245. 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"
  1246. >
  1247. <button
  1248. onClick={() => toggleBatchCollapsed(row.batchId)}
  1249. className="w-full flex items-center gap-2 sm:gap-3 px-3 py-2 hover:bg-bambu-dark/30 transition-colors text-left"
  1250. title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
  1251. >
  1252. {collapsed ? (
  1253. <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
  1254. ) : (
  1255. <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
  1256. )}
  1257. {collapsed ? (
  1258. <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300 shrink-0" />
  1259. ) : (
  1260. <PackageOpen className="w-5 h-5 text-cyan-700 dark:text-cyan-300 shrink-0" />
  1261. )}
  1262. <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
  1263. {row.batchName}
  1264. </span>
  1265. <div className="flex items-center gap-2 text-xs text-bambu-gray shrink-0">
  1266. {completed > 0 && (
  1267. <span className="flex items-center gap-1 text-emerald-700 dark:text-emerald-400">
  1268. <CheckCircle className="w-3 h-3" />
  1269. {completed}
  1270. </span>
  1271. )}
  1272. {failed > 0 && (
  1273. <span className="flex items-center gap-1 text-red-700 dark:text-red-400">
  1274. <XCircle className="w-3 h-3" />
  1275. {failed}
  1276. </span>
  1277. )}
  1278. {skipped > 0 && (
  1279. <span className="flex items-center gap-1 text-orange-700 dark:text-orange-400">
  1280. <SkipForward className="w-3 h-3" />
  1281. {skipped}
  1282. </span>
  1283. )}
  1284. {cancelled > 0 && (
  1285. <span className="flex items-center gap-1 text-gray-400">
  1286. <Ban className="w-3 h-3" />
  1287. {cancelled}
  1288. </span>
  1289. )}
  1290. <span title={latest ?? undefined}>
  1291. {latest ? formatRelativeTime(latest, timeFormat, t) : null}
  1292. </span>
  1293. </div>
  1294. </button>
  1295. {!collapsed && (
  1296. <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">
  1297. {row.items.map((child) => (
  1298. <CompactHistoryRow
  1299. key={child.id}
  1300. item={child}
  1301. onRemove={() => onRemove(child)}
  1302. onRequeue={() => onRequeue(child)}
  1303. timeFormat={timeFormat}
  1304. hasPermission={hasPermission}
  1305. canModify={canModify}
  1306. t={t}
  1307. />
  1308. ))}
  1309. </div>
  1310. )}
  1311. </div>
  1312. );
  1313. })}
  1314. </div>
  1315. {items.length > visibleCount && (
  1316. <div className="mt-4 flex flex-col items-center gap-2">
  1317. <Button variant="secondary" size="sm" onClick={onShowMore}>
  1318. {t('queue.history.showMore')}
  1319. </Button>
  1320. <span className="text-xs text-bambu-gray">
  1321. {t('queue.history.showingCount', {
  1322. shown: Math.min(visibleCount, items.length),
  1323. total: items.length,
  1324. })}
  1325. </span>
  1326. </div>
  1327. )}
  1328. </div>
  1329. );
  1330. }
  1331. const HISTORY_PAGE_SIZE = 50;
  1332. export function QueuePage() {
  1333. const { t } = useTranslation();
  1334. const queryClient = useQueryClient();
  1335. const { showToast } = useToast();
  1336. const { hasPermission, hasAnyPermission, canModify } = useAuth();
  1337. const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
  1338. const [filterStatus, setFilterStatus] = useState<string>('');
  1339. const [filterLocation, setFilterLocation] = useState<string>('');
  1340. const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
  1341. const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
  1342. const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
  1343. const [confirmAction, setConfirmAction] = useState<{
  1344. type: 'cancel' | 'remove' | 'stop';
  1345. item: PrintQueueItem;
  1346. } | null>(null);
  1347. const [selectedItems, setSelectedItems] = useState<number[]>([]);
  1348. const [showBulkEditModal, setShowBulkEditModal] = useState(false);
  1349. // #1818: per-printer Resume-after-failure confirm modal. Tracks which
  1350. // printer's gate the user is about to clear; null when no modal is open.
  1351. const [resumeConfirm, setResumeConfirm] = useState<{
  1352. printerId: number;
  1353. printerName: string;
  1354. skippedCount: number;
  1355. } | null>(null);
  1356. const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
  1357. const saved = localStorage.getItem('queue.historySortBy');
  1358. return (saved as 'date' | 'name' | 'printer') || 'date';
  1359. });
  1360. const [historySortAsc, setHistorySortAsc] = useState(() => {
  1361. const saved = localStorage.getItem('queue.historySortAsc');
  1362. return saved !== null ? saved === 'true' : false;
  1363. });
  1364. // #2682: History renders progressively — start at one page, grow on demand.
  1365. // Reset happens only on a deliberate re-sort / filter change (below), NOT on
  1366. // the periodic queue poll, so an expanded view doesn't snap back mid-scroll.
  1367. const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
  1368. const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
  1369. const saved = localStorage.getItem('queue.pendingSortBy');
  1370. return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
  1371. });
  1372. const [pendingSortAsc, setPendingSortAsc] = useState(() => {
  1373. const saved = localStorage.getItem('queue.pendingSortAsc');
  1374. return saved !== null ? saved === 'true' : true;
  1375. });
  1376. // historyCollapsed legacy state retained only for localStorage migration; the
  1377. // History tab renders unconditionally so this no longer drives the UI.
  1378. // Tabbed page structure: Active queue stays as the main view; History
  1379. // and Timeline split off. Persists per-user via localStorage.
  1380. const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => {
  1381. // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
  1382. // right tab. localStorage holds the per-user last-selected fallback.
  1383. const search = new URLSearchParams(window.location.search);
  1384. const url = search.get('tab');
  1385. if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') {
  1386. return url;
  1387. }
  1388. const saved = localStorage.getItem('queue.activeTab');
  1389. if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
  1390. return 'queue';
  1391. });
  1392. // Active-tab layout toggle. "position" = today's flat list; "printer"
  1393. // groups items under per-printer section headers with aggregate stats.
  1394. const [activeLayout, setActiveLayout] = useState<'position' | 'printer'>(() => {
  1395. const saved = localStorage.getItem('queue.activeLayout');
  1396. return saved === 'printer' ? 'printer' : 'position';
  1397. });
  1398. // Per-batch collapse state, keyed by batch_id. Default = collapsed
  1399. // (matches the SimplyPrint/Files convention — show the rollup first).
  1400. const [batchCollapsed, setBatchCollapsed] = useState<Record<number, boolean>>(() => {
  1401. try {
  1402. const saved = localStorage.getItem('queue.batchCollapsed');
  1403. return saved ? JSON.parse(saved) : {};
  1404. } catch {
  1405. return {};
  1406. }
  1407. });
  1408. // Multi-drag bookkeeping for DragOverlay. Numeric for single items, string
  1409. // `batch-<id>` when a whole group is being dragged.
  1410. const [activeDragId, setActiveDragId] = useState<number | string | null>(null);
  1411. // "Group as batch" modal.
  1412. const [groupBatchModal, setGroupBatchModal] = useState(false);
  1413. // Ungroup confirm.
  1414. const [ungroupBatchId, setUngroupBatchId] = useState<number | null>(null);
  1415. // Persist sort settings to localStorage
  1416. useEffect(() => {
  1417. localStorage.setItem('queue.historySortBy', historySortBy);
  1418. }, [historySortBy]);
  1419. useEffect(() => {
  1420. localStorage.setItem('queue.historySortAsc', String(historySortAsc));
  1421. }, [historySortAsc]);
  1422. // Collapse History back to a single page when the user re-sorts or changes
  1423. // the location filter (deliberate view changes). Intentionally excludes the
  1424. // queue poll so periodic refetches keep the expanded count.
  1425. useEffect(() => {
  1426. setHistoryVisibleCount(HISTORY_PAGE_SIZE);
  1427. }, [historySortBy, historySortAsc, filterLocation]);
  1428. useEffect(() => {
  1429. localStorage.setItem('queue.pendingSortBy', pendingSortBy);
  1430. }, [pendingSortBy]);
  1431. useEffect(() => {
  1432. localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
  1433. }, [pendingSortAsc]);
  1434. useEffect(() => {
  1435. localStorage.setItem('queue.activeTab', activeTab);
  1436. }, [activeTab]);
  1437. useEffect(() => {
  1438. localStorage.setItem('queue.activeLayout', activeLayout);
  1439. }, [activeLayout]);
  1440. useEffect(() => {
  1441. localStorage.setItem('queue.batchCollapsed', JSON.stringify(batchCollapsed));
  1442. }, [batchCollapsed]);
  1443. const sensors = useSensors(
  1444. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  1445. useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  1446. );
  1447. const { data: settings } = useQuery({
  1448. queryKey: ['settings'],
  1449. queryFn: api.getSettings,
  1450. });
  1451. const timeFormat: TimeFormat = settings?.time_format || 'system';
  1452. const { data: queue, isLoading } = useQuery({
  1453. queryKey: ['queue', filterPrinter, filterStatus],
  1454. queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
  1455. refetchInterval: 5000,
  1456. });
  1457. const { data: printers } = useQuery({
  1458. queryKey: ['printers'],
  1459. queryFn: () => api.getPrinters(),
  1460. });
  1461. const sjfMutation = useMutation({
  1462. mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
  1463. onSuccess: () => {
  1464. queryClient.invalidateQueries({ queryKey: ['settings'] });
  1465. },
  1466. });
  1467. const cancelMutation = useMutation({
  1468. mutationFn: (id: number) => api.cancelQueueItem(id),
  1469. onSuccess: () => {
  1470. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1471. showToast(t('queue.toast.cancelled'));
  1472. },
  1473. onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
  1474. });
  1475. const removeMutation = useMutation({
  1476. mutationFn: (id: number) => api.removeFromQueue(id),
  1477. onSuccess: () => {
  1478. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1479. showToast(t('queue.toast.removed'));
  1480. },
  1481. onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
  1482. });
  1483. const stopMutation = useMutation({
  1484. mutationFn: (id: number) => api.stopQueueItem(id),
  1485. onSuccess: () => {
  1486. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1487. showToast(t('queue.toast.stopped'));
  1488. },
  1489. onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
  1490. });
  1491. // Filament-deficit confirmation state (#1496). When the backend returns
  1492. // 409 with `code=insufficient_filament` we stash the deficit + item id
  1493. // here; the modal at the bottom of the page reads it and the "Print
  1494. // Anyway" path re-issues the start with `skipFilamentCheck=true`.
  1495. const [filamentShortConfirm, setFilamentShortConfirm] = useState<{
  1496. itemId: number;
  1497. deficit: Array<{
  1498. slot_id: number;
  1499. required_grams: number;
  1500. remaining_grams: number | null;
  1501. filament_type?: string | null;
  1502. }>;
  1503. } | null>(null);
  1504. const startMutation = useMutation({
  1505. mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
  1506. api.startQueueItem(id, { skipFilamentCheck }),
  1507. onSuccess: () => {
  1508. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1509. showToast(t('queue.toast.released'));
  1510. setFilamentShortConfirm(null);
  1511. },
  1512. onError: (error: unknown, variables) => {
  1513. if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
  1514. const deficitRaw = (error.detail?.deficit ?? []) as Array<{
  1515. slot_id: number;
  1516. required_grams: number;
  1517. remaining_grams: number | null;
  1518. filament_type?: string | null;
  1519. }>;
  1520. setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
  1521. return;
  1522. }
  1523. showToast(t('queue.toast.startFailed'), 'error');
  1524. },
  1525. });
  1526. const reorderMutation = useMutation({
  1527. mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
  1528. onSuccess: () => {
  1529. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1530. },
  1531. onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
  1532. });
  1533. const clearHistoryMutation = useMutation({
  1534. mutationFn: async () => {
  1535. const historyItems = queue?.filter(i =>
  1536. ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
  1537. ) || [];
  1538. for (const item of historyItems) {
  1539. await api.removeFromQueue(item.id);
  1540. }
  1541. return historyItems.length;
  1542. },
  1543. onSuccess: (count) => {
  1544. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1545. showToast(t('queue.toast.historyCleared', { count }));
  1546. },
  1547. onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
  1548. });
  1549. const bulkUpdateMutation = useMutation({
  1550. mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
  1551. onSuccess: (result) => {
  1552. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1553. setSelectedItems([]);
  1554. setShowBulkEditModal(false);
  1555. showToast(result.message);
  1556. },
  1557. onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
  1558. });
  1559. const bulkCancelMutation = useMutation({
  1560. mutationFn: async (ids: number[]) => {
  1561. for (const id of ids) {
  1562. await api.cancelQueueItem(id);
  1563. }
  1564. return ids.length;
  1565. },
  1566. onSuccess: (count) => {
  1567. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1568. setSelectedItems([]);
  1569. showToast(t('queue.toast.bulkCancelled', { count }));
  1570. },
  1571. onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
  1572. });
  1573. const resumeAfterFailureMutation = useMutation({
  1574. mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId),
  1575. onSuccess: (result) => {
  1576. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1577. setResumeConfirm(null);
  1578. showToast(
  1579. t('queue.toast.resumedAfterFailure', {
  1580. restored: result.restored,
  1581. acknowledged: result.acknowledged,
  1582. }),
  1583. );
  1584. },
  1585. onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
  1586. });
  1587. const createBatchMutation = useMutation({
  1588. mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
  1589. onSuccess: (batch) => {
  1590. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1591. setSelectedItems([]);
  1592. setGroupBatchModal(false);
  1593. // New batches start expanded so the user sees what they just grouped.
  1594. setBatchCollapsed((prev) => ({ ...prev, [batch.id]: false }));
  1595. showToast(t('queue.toast.batchCreated', { name: batch.name }));
  1596. },
  1597. onError: () => showToast(t('queue.toast.batchCreateFailed'), 'error'),
  1598. });
  1599. const ungroupBatchMutation = useMutation({
  1600. mutationFn: (id: number) => api.ungroupBatch(id),
  1601. onSuccess: (result) => {
  1602. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1603. setUngroupBatchId(null);
  1604. showToast(t('queue.toast.batchUngrouped', { count: result.ungrouped_count }));
  1605. },
  1606. onError: () => showToast(t('queue.toast.batchUngroupFailed'), 'error'),
  1607. });
  1608. const handleToggleSelect = (id: number) => {
  1609. setSelectedItems(prev =>
  1610. prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
  1611. );
  1612. };
  1613. // Get unique locations from printers for the filter dropdown
  1614. const uniqueLocations = useMemo(() => {
  1615. const locations = new Set<string>();
  1616. printers?.forEach(p => {
  1617. if (p.location) locations.add(p.location);
  1618. });
  1619. // Also include locations from queue items (for model-based assignments)
  1620. queue?.forEach(item => {
  1621. if (item.target_location) locations.add(item.target_location);
  1622. });
  1623. return Array.from(locations).sort();
  1624. }, [printers, queue]);
  1625. // Helper to check if a queue item matches the location filter
  1626. const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
  1627. if (!filterLocation) return true;
  1628. // For model-based assignments, check target_location
  1629. if (item.target_location) return item.target_location === filterLocation;
  1630. // For printer-based assignments, check the printer's location
  1631. if (item.printer_id) {
  1632. const printer = printers?.find(p => p.id === item.printer_id);
  1633. return printer?.location === filterLocation;
  1634. }
  1635. return false;
  1636. }, [filterLocation, printers]);
  1637. const pendingItems = useMemo(() => {
  1638. let items = queue?.filter(i => i.status === 'pending') || [];
  1639. // Apply location filter
  1640. if (filterLocation) {
  1641. items = items.filter(matchesLocationFilter);
  1642. }
  1643. // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
  1644. const getScheduledTime = (item: PrintQueueItem): number => {
  1645. if (!item.scheduled_time) return 0;
  1646. const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;
  1647. // Placeholder dates (> 6 months out) are treated as ASAP
  1648. const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
  1649. return time > sixMonthsFromNow ? 0 : time;
  1650. };
  1651. // When SJF is enabled, override sort to match scheduler order
  1652. if (settings?.queue_shortest_first) {
  1653. return [...items].sort((a, b) => {
  1654. // Group by printer first (nulls = model-based, grouped by target_model)
  1655. const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);
  1656. const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);
  1657. if (aPrinter !== bPrinter) return aPrinter - bPrinter;
  1658. // Within same printer/model: jumped items first (starvation guard)
  1659. const aJumped = a.been_jumped ? 1 : 0;
  1660. const bJumped = b.been_jumped ? 1 : 0;
  1661. if (aJumped !== bJumped) return bJumped - aJumped;
  1662. // Shortest print time next (nulls last)
  1663. const aTime = a.print_time_seconds ?? Infinity;
  1664. const bTime = b.print_time_seconds ?? Infinity;
  1665. if (aTime !== bTime) return aTime - bTime;
  1666. // Position as tiebreaker
  1667. return a.position - b.position;
  1668. });
  1669. }
  1670. return [...items].sort((a, b) => {
  1671. let cmp: number;
  1672. if (pendingSortBy === 'name') {
  1673. const aName = a.archive_name || a.library_file_name || '';
  1674. const bName = b.archive_name || b.library_file_name || '';
  1675. cmp = aName.localeCompare(bName);
  1676. } else if (pendingSortBy === 'printer') {
  1677. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  1678. } else if (pendingSortBy === 'time') {
  1679. // Sort by scheduled start time (when print will begin)
  1680. cmp = getScheduledTime(a) - getScheduledTime(b);
  1681. } else {
  1682. cmp = a.position - b.position;
  1683. }
  1684. return pendingSortAsc ? cmp : -cmp;
  1685. });
  1686. }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);
  1687. const handleSelectAll = () => {
  1688. const allPendingIds = pendingItems.map(i => i.id);
  1689. if (selectedItems.length === allPendingIds.length) {
  1690. setSelectedItems([]);
  1691. } else {
  1692. setSelectedItems(allPendingIds);
  1693. }
  1694. };
  1695. const activeItems = useMemo(() => {
  1696. let items = queue?.filter(i => i.status === 'printing') || [];
  1697. if (filterLocation) {
  1698. items = items.filter(matchesLocationFilter);
  1699. }
  1700. return items;
  1701. }, [queue, filterLocation, matchesLocationFilter]);
  1702. // Queue items eligible for an "if started now" ETA (#2740).
  1703. //
  1704. // The ETA answers "when would this finish if it began right now", so it may
  1705. // only appear on items that really could begin right now. Deriving that from
  1706. // waiting_reason alone is not enough: the scheduler only writes that field on
  1707. // the model-based assignment path (print_scheduler.py), so an item pinned to a
  1708. // specific printer sits behind a running job with waiting_reason still NULL.
  1709. //
  1710. // Computed from the unfiltered queue on purpose — hiding a printer behind the
  1711. // location filter must not make its printer look free.
  1712. const etaEligibleIds = useMemo(() => {
  1713. const eligible = new Set<number>();
  1714. if (!queue) return eligible;
  1715. const busyPrinters = new Set<number>();
  1716. queue.forEach(item => {
  1717. if (item.status === 'printing' && item.printer_id) busyPrinters.add(item.printer_id);
  1718. });
  1719. const isFutureScheduled = (item: PrintQueueItem): boolean => {
  1720. if (!item.scheduled_time) return false;
  1721. return (parseUTCDate(item.scheduled_time)?.getTime() ?? 0) > Date.now();
  1722. };
  1723. // Mirrors the scheduler's own ordering so "next up" here means the item the
  1724. // scheduler would actually dispatch next, not whatever the user sorted by.
  1725. const schedulerOrder = (a: PrintQueueItem, b: PrintQueueItem): number => {
  1726. if (settings?.queue_shortest_first) {
  1727. const aJumped = a.been_jumped ? 1 : 0;
  1728. const bJumped = b.been_jumped ? 1 : 0;
  1729. if (aJumped !== bJumped) return bJumped - aJumped;
  1730. const aTime = a.print_time_seconds ?? Infinity;
  1731. const bTime = b.print_time_seconds ?? Infinity;
  1732. if (aTime !== bTime) return aTime - bTime;
  1733. }
  1734. return a.position - b.position;
  1735. };
  1736. // Claimants for each printer, in the order the scheduler would take them.
  1737. // Staged and future-scheduled items are excluded: the scheduler skips both
  1738. // without marking the printer busy, so neither holds up the item behind it.
  1739. const contenders = new Map<number, PrintQueueItem[]>();
  1740. queue
  1741. .filter(
  1742. item =>
  1743. item.status === 'pending' &&
  1744. item.printer_id != null &&
  1745. !item.manual_start &&
  1746. !isFutureScheduled(item)
  1747. )
  1748. .sort(schedulerOrder)
  1749. .forEach(item => {
  1750. const list = contenders.get(item.printer_id!) ?? [];
  1751. list.push(item);
  1752. contenders.set(item.printer_id!, list);
  1753. });
  1754. queue.forEach(item => {
  1755. if (item.status !== 'pending') return;
  1756. // Blocked, scheduled for later, or no usable duration to add.
  1757. if (item.waiting_reason) return;
  1758. if (isFutureScheduled(item)) return;
  1759. if (item.print_time_seconds == null || item.print_time_seconds <= 0) return;
  1760. // Conditional on an earlier print's outcome, which the UI cannot see: the
  1761. // scheduler may skip it outright rather than ever running it.
  1762. if (item.require_previous_success) return;
  1763. // Model-based items have no printer yet; an empty waiting_reason is the
  1764. // scheduler saying it found one, so trust that.
  1765. if (item.printer_id == null) {
  1766. eligible.add(item.id);
  1767. return;
  1768. }
  1769. if (busyPrinters.has(item.printer_id)) return;
  1770. // Staged items wait on the user, not on the queue, so they are startable
  1771. // whenever their printer is free regardless of what is queued ahead.
  1772. if (item.manual_start) {
  1773. eligible.add(item.id);
  1774. return;
  1775. }
  1776. if (contenders.get(item.printer_id)?.[0]?.id === item.id) eligible.add(item.id);
  1777. });
  1778. return eligible;
  1779. }, [queue, settings?.queue_shortest_first]);
  1780. // The ETA is "now + duration", so it goes stale on its own. Nothing else
  1781. // re-renders these rows while the queue payload is unchanged (react-query's
  1782. // structural sharing keeps the reference stable), so drive it from a clock of
  1783. // our own. Only runs while an ETA is actually on screen.
  1784. const [etaNow, setEtaNow] = useState(() => Date.now());
  1785. const hasEtas = etaEligibleIds.size > 0;
  1786. useEffect(() => {
  1787. if (!hasEtas) return;
  1788. setEtaNow(Date.now());
  1789. const id = setInterval(() => setEtaNow(Date.now()), 30000);
  1790. return () => clearInterval(id);
  1791. }, [hasEtas]);
  1792. // Get unique printer IDs from active items to fetch their statuses
  1793. const activePrinterIds = useMemo(() => {
  1794. const ids = new Set<number>();
  1795. activeItems.forEach(item => {
  1796. if (item.printer_id) ids.add(item.printer_id);
  1797. });
  1798. return Array.from(ids);
  1799. }, [activeItems]);
  1800. // Fetch printer statuses for printers with active jobs
  1801. const printerStatusQueries = useQueries({
  1802. queries: activePrinterIds.map(printerId => ({
  1803. queryKey: ['printerStatus', printerId],
  1804. queryFn: () => api.getPrinterStatus(printerId),
  1805. refetchInterval: 5000,
  1806. })),
  1807. });
  1808. // Build a map of printer_id -> state for quick lookup
  1809. const printerStateMap = useMemo(() => {
  1810. const map: Record<number, string | null> = {};
  1811. activePrinterIds.forEach((printerId, index) => {
  1812. const result = printerStatusQueries[index];
  1813. if (result?.data?.state) {
  1814. map[printerId] = result.data.state;
  1815. }
  1816. });
  1817. return map;
  1818. }, [activePrinterIds, printerStatusQueries]);
  1819. // Build a map of printer_id -> full status for timeline view
  1820. const printerStatusMap = useMemo(() => {
  1821. const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};
  1822. activePrinterIds.forEach((printerId, index) => {
  1823. const result = printerStatusQueries[index];
  1824. if (result?.data) {
  1825. map[printerId] = {
  1826. progress: result.data.progress ?? undefined,
  1827. remaining_time: result.data.remaining_time ?? undefined,
  1828. state: result.data.state ?? undefined,
  1829. };
  1830. }
  1831. });
  1832. return map;
  1833. }, [activePrinterIds, printerStatusQueries]);
  1834. const historyItems = useMemo(() => {
  1835. let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
  1836. if (filterLocation) {
  1837. items = items.filter(matchesLocationFilter);
  1838. }
  1839. return [...items].sort((a, b) => {
  1840. let cmp: number;
  1841. if (historySortBy === 'name') {
  1842. const aName = a.archive_name || a.library_file_name || '';
  1843. const bName = b.archive_name || b.library_file_name || '';
  1844. cmp = aName.localeCompare(bName);
  1845. } else if (historySortBy === 'printer') {
  1846. cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
  1847. } else {
  1848. // Default: by date - most recent first (desc) is the natural order
  1849. cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
  1850. }
  1851. return historySortAsc ? -cmp : cmp;
  1852. });
  1853. }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
  1854. // Calculate total queue time
  1855. const totalQueueTime = useMemo(() => {
  1856. return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
  1857. }, [pendingItems]);
  1858. // Calculate total material weight
  1859. const totalWeight = useMemo(() => {
  1860. return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
  1861. }, [pendingItems]);
  1862. const handleDragStart = (event: DragStartEvent) => {
  1863. const id = event.active.id;
  1864. setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null);
  1865. };
  1866. const handleDragEnd = (event: DragEndEvent) => {
  1867. const { active, over } = event;
  1868. setActiveDragId(null);
  1869. if (!over || active.id === over.id) return;
  1870. // Resolve dragged source → movingIds (preserving order from pendingItems).
  1871. // - `batch-<id>`: every child of that batch, in their current order
  1872. // - selected + dragged is one of them: contiguous multi-drag block
  1873. // - otherwise: single row
  1874. let movingIds: number[];
  1875. const activeId = active.id;
  1876. if (typeof activeId === 'string' && activeId.startsWith('batch-')) {
  1877. const batchId = Number(activeId.slice('batch-'.length));
  1878. movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id);
  1879. } else {
  1880. const draggedId = activeId as number;
  1881. movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
  1882. ? selectedItems.slice().sort((a, b) => {
  1883. const ai = pendingItems.findIndex((i) => i.id === a);
  1884. const bi = pendingItems.findIndex((i) => i.id === b);
  1885. return ai - bi;
  1886. })
  1887. : [draggedId];
  1888. }
  1889. if (movingIds.length === 0) return;
  1890. // Resolve drop target → index inside pendingItems. A `batch-<id>` drop
  1891. // target anchors at the batch's first child, so dropping above another
  1892. // batch lands the moving block immediately before it.
  1893. let overIndex: number;
  1894. const overId = over.id;
  1895. if (typeof overId === 'string' && overId.startsWith('batch-')) {
  1896. const overBatchId = Number(overId.slice('batch-'.length));
  1897. overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId);
  1898. } else {
  1899. overIndex = pendingItems.findIndex((i) => i.id === overId);
  1900. }
  1901. if (overIndex === -1) return;
  1902. // Remove the moving items, then re-insert at overIndex (adjusted).
  1903. const overAnchor = pendingItems[overIndex];
  1904. const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
  1905. let insertAt = remaining.findIndex((i) => i.id === overAnchor.id);
  1906. if (insertAt === -1) insertAt = overIndex;
  1907. // If dragging downward across the drop target, insert AFTER it; upward
  1908. // = before. dnd-kit's `over` is the row under the pointer, not the gap.
  1909. const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]);
  1910. if (firstMovingIndex < overIndex) insertAt += 1;
  1911. const reordered = [
  1912. ...remaining.slice(0, insertAt),
  1913. ...movingIds
  1914. .map((id) => pendingItems.find((i) => i.id === id))
  1915. .filter((x): x is PrintQueueItem => !!x),
  1916. ...remaining.slice(insertAt),
  1917. ];
  1918. const updates = reordered.map((item, index) => ({
  1919. id: item.id,
  1920. position: index + 1,
  1921. }));
  1922. reorderMutation.mutate(updates);
  1923. };
  1924. // Group pending items by batch_id. Items with batch_id null render as
  1925. // standalone rows; items sharing a batch_id render as a collapsible
  1926. // group keyed by that id. Items inside a group keep their original
  1927. // relative order from pendingItems.
  1928. const groupedRows = useMemo<QueueRow[]>(() => {
  1929. const rows: QueueRow[] = [];
  1930. const seenBatches = new Set<number>();
  1931. for (const item of pendingItems) {
  1932. if (item.batch_id != null) {
  1933. if (seenBatches.has(item.batch_id)) continue;
  1934. seenBatches.add(item.batch_id);
  1935. const siblings = pendingItems.filter((s) => s.batch_id === item.batch_id);
  1936. rows.push({
  1937. kind: 'batch',
  1938. batchId: item.batch_id,
  1939. batchName: item.batch_name || t('queue.batch.defaultName'),
  1940. items: siblings,
  1941. });
  1942. } else {
  1943. rows.push({ kind: 'item', item });
  1944. }
  1945. }
  1946. return rows;
  1947. }, [pendingItems, t]);
  1948. // Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on
  1949. // phones and touch-drag is unreliable, so pending rows get up/down arrows.
  1950. // Reordering only has a defined meaning in the manual "position" sort with
  1951. // SJF off — any other sort re-orders the list itself, so we don't offer it.
  1952. const canReorderManually =
  1953. hasPermission('queue:reorder') &&
  1954. pendingSortBy === 'position' &&
  1955. !settings?.queue_shortest_first;
  1956. const rowItemIds = (row: QueueRow): number[] =>
  1957. row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id);
  1958. // Move a block of items to sit immediately before (or after) an anchor item
  1959. // in the global pending order, then persist — the same remove-and-reinsert
  1960. // shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item
  1961. // id keeps it correct in the printer-grouped layout, where a bucket's rows
  1962. // aren't contiguous in the global order.
  1963. const moveBlockRelativeTo = (
  1964. movingIds: number[],
  1965. anchorId: number,
  1966. placeAfter: boolean,
  1967. ) => {
  1968. const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
  1969. let insertAt = remaining.findIndex((i) => i.id === anchorId);
  1970. if (insertAt === -1) return;
  1971. if (placeAfter) insertAt += 1;
  1972. const movingItems = movingIds
  1973. .map((id) => pendingItems.find((i) => i.id === id))
  1974. .filter((x): x is PrintQueueItem => !!x);
  1975. const reordered = [
  1976. ...remaining.slice(0, insertAt),
  1977. ...movingItems,
  1978. ...remaining.slice(insertAt),
  1979. ];
  1980. reorderMutation.mutate(
  1981. reordered.map((item, index) => ({ id: item.id, position: index + 1 })),
  1982. );
  1983. };
  1984. // Build up/down thunks for the row at `idx` within its displayed sibling
  1985. // list (the flat list, or a single printer bucket). Undefined at a boundary
  1986. // (button rendered disabled) or when manual reorder isn't available.
  1987. const rowMovers = (
  1988. rows: QueueRow[],
  1989. idx: number,
  1990. ): { onMoveUp?: () => void; onMoveDown?: () => void } => {
  1991. if (!canReorderManually) return {};
  1992. const moving = rowItemIds(rows[idx]);
  1993. const onMoveUp =
  1994. idx > 0
  1995. ? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false)
  1996. : undefined;
  1997. const onMoveDown =
  1998. idx < rows.length - 1
  1999. ? () => {
  2000. const next = rowItemIds(rows[idx + 1]);
  2001. moveBlockRelativeTo(moving, next[next.length - 1], true);
  2002. }
  2003. : undefined;
  2004. return { onMoveUp, onMoveDown };
  2005. };
  2006. // SortableContext ID list.
  2007. // - Standalone pending items: their numeric id.
  2008. // - Batch parents: the synthetic `batch-<id>` string, always present so the
  2009. // group itself is draggable and acts as a drop target whether collapsed
  2010. // or expanded.
  2011. // - Expanded batch children: their numeric id, so within-batch reorder
  2012. // keeps working. Collapsed children are detached from the DOM and
  2013. // intentionally omitted to keep dnd-kit's collision resolver clean.
  2014. const sortableIds = useMemo<(number | string)[]>(() => {
  2015. const ids: (number | string)[] = [];
  2016. for (const row of groupedRows) {
  2017. if (row.kind === 'item') {
  2018. ids.push(row.item.id);
  2019. } else {
  2020. ids.push(`batch-${row.batchId}`);
  2021. const collapsed = batchCollapsed[row.batchId] ?? true;
  2022. if (!collapsed) {
  2023. for (const child of row.items) ids.push(child.id);
  2024. }
  2025. }
  2026. }
  2027. return ids;
  2028. }, [groupedRows, batchCollapsed]);
  2029. // Items already in a batch can't be grouped; "Group as batch" only shows
  2030. // when 2+ ungrouped items are selected.
  2031. const canGroupSelected = useMemo(() => {
  2032. if (selectedItems.length < 2) return false;
  2033. return selectedItems.every((id) => {
  2034. const item = pendingItems.find((p) => p.id === id);
  2035. return item && item.batch_id == null;
  2036. });
  2037. }, [selectedItems, pendingItems]);
  2038. const toggleBatchCollapsed = (id: number) => {
  2039. setBatchCollapsed((prev) => ({ ...prev, [id]: !(prev[id] ?? true) }));
  2040. };
  2041. // Group by printer view. Items are bucketed by printer_id (null = model
  2042. // assignment or unassigned, keyed by target_model or "unassigned").
  2043. type PrinterBucket = {
  2044. key: string;
  2045. printerId: number | null;
  2046. targetModel: string | null;
  2047. label: string;
  2048. isUnassigned: boolean;
  2049. rows: QueueRow[];
  2050. };
  2051. const printerBuckets = useMemo<PrinterBucket[]>(() => {
  2052. const buckets = new Map<string, PrinterBucket>();
  2053. const bucketForItem = (item: PrintQueueItem): { key: string; label: string; printerId: number | null; targetModel: string | null; isUnassigned: boolean } => {
  2054. if (item.printer_id) {
  2055. return {
  2056. key: `printer:${item.printer_id}`,
  2057. label: item.printer_name || `Printer #${item.printer_id}`,
  2058. printerId: item.printer_id,
  2059. targetModel: null,
  2060. isUnassigned: false,
  2061. };
  2062. }
  2063. if (item.target_model) {
  2064. return {
  2065. key: `model:${item.target_model}`,
  2066. label: `${t('queue.filter.any')} ${item.target_model}`,
  2067. printerId: null,
  2068. targetModel: item.target_model,
  2069. isUnassigned: false,
  2070. };
  2071. }
  2072. return {
  2073. key: 'unassigned',
  2074. label: t('queue.filter.unassigned'),
  2075. printerId: null,
  2076. targetModel: null,
  2077. isUnassigned: true,
  2078. };
  2079. };
  2080. for (const row of groupedRows) {
  2081. const representative = row.kind === 'item' ? row.item : row.items[0];
  2082. if (!representative) continue;
  2083. const meta = bucketForItem(representative);
  2084. if (!buckets.has(meta.key)) {
  2085. buckets.set(meta.key, { ...meta, rows: [] });
  2086. }
  2087. buckets.get(meta.key)!.rows.push(row);
  2088. }
  2089. return Array.from(buckets.values()).sort((a, b) => {
  2090. if (a.isUnassigned && !b.isUnassigned) return 1;
  2091. if (!a.isUnassigned && b.isUnassigned) return -1;
  2092. return a.label.localeCompare(b.label);
  2093. });
  2094. }, [groupedRows, t]);
  2095. // #1818: printers whose queue is gated by a prior failure that's poisoning
  2096. // downstream `require_previous_success` items. We surface a per-printer
  2097. // Resume banner above the active queue so the user can clear the gate +
  2098. // restore the skipped jobs in one click, without re-queuing each one.
  2099. // Detection key: skipped + the scheduler's exact gate string. Other skip
  2100. // reasons (filament deficit, etc.) get their own UX and stay untouched.
  2101. const gateBlockedPrinters = useMemo<
  2102. Array<{ printerId: number; printerName: string; skippedCount: number }>
  2103. >(() => {
  2104. const counts = new Map<number, { name: string; count: number }>();
  2105. queue?.forEach((item) => {
  2106. if (
  2107. item.status === 'skipped' &&
  2108. item.error_message === 'Previous print failed or was aborted' &&
  2109. item.printer_id
  2110. ) {
  2111. const existing = counts.get(item.printer_id);
  2112. if (existing) {
  2113. existing.count += 1;
  2114. } else {
  2115. counts.set(item.printer_id, {
  2116. name: item.printer_name || `Printer #${item.printer_id}`,
  2117. count: 1,
  2118. });
  2119. }
  2120. }
  2121. });
  2122. return Array.from(counts.entries())
  2123. .map(([printerId, { name, count }]) => ({
  2124. printerId,
  2125. printerName: name,
  2126. skippedCount: count,
  2127. }))
  2128. .sort((a, b) => a.printerName.localeCompare(b.printerName));
  2129. }, [queue]);
  2130. const aggregateForRows = (rows: QueueRow[]) => {
  2131. let count = 0;
  2132. let time = 0;
  2133. let weight = 0;
  2134. for (const row of rows) {
  2135. const items = row.kind === 'item' ? [row.item] : row.items;
  2136. for (const item of items) {
  2137. count += 1;
  2138. time += item.print_time_seconds || 0;
  2139. weight += item.filament_used_grams || 0;
  2140. }
  2141. }
  2142. return { count, time, weight };
  2143. };
  2144. return (
  2145. <div className="p-4 md:p-8">
  2146. {/* Header */}
  2147. <div className="flex items-center justify-between mb-6">
  2148. <div>
  2149. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  2150. <ListOrdered className="w-7 h-7 text-bambu-green" />
  2151. {t('queue.title')}
  2152. </h1>
  2153. <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
  2154. </div>
  2155. </div>
  2156. {/* Tab strip — Active queue is the main view; History and Timeline
  2157. live in their own tabs so the queue page stays focused. */}
  2158. <div className="flex gap-1 border-b border-bambu-dark-tertiary mb-6 overflow-x-auto">
  2159. {([
  2160. { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
  2161. { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
  2162. { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
  2163. // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
  2164. // its own sidebar entry so the Print Queue page is the single
  2165. // place an operator looks for "what's running / what ran".
  2166. { id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null },
  2167. ]).map(({ id, label, icon: Icon, count }) => (
  2168. <button
  2169. key={id}
  2170. onClick={() => setActiveTab(id)}
  2171. className={`px-4 py-2.5 text-sm flex items-center gap-2 border-b-2 -mb-px transition-colors whitespace-nowrap ${
  2172. activeTab === id
  2173. ? 'text-white border-bambu-green font-medium'
  2174. : 'text-bambu-gray border-transparent hover:text-white'
  2175. }`}
  2176. >
  2177. <Icon className="w-4 h-4" />
  2178. {label}
  2179. {count !== null && count > 0 && (
  2180. <span className={`text-xs px-1.5 py-0.5 rounded-full ${
  2181. activeTab === id ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
  2182. }`}>
  2183. {count}
  2184. </span>
  2185. )}
  2186. </button>
  2187. ))}
  2188. </div>
  2189. {/* Summary Stats — about the print queue, not pipelines. */}
  2190. {activeTab !== 'pipelines' && <QueueStatsBar
  2191. activeCount={activeItems.length}
  2192. pendingCount={pendingItems.length}
  2193. totalTime={totalQueueTime}
  2194. totalWeight={totalWeight}
  2195. historyCount={historyItems.length}
  2196. t={t}
  2197. />}
  2198. {/* #1818: Resume-after-failure banner. One row per printer whose queue
  2199. is gated by a prior failed/aborted print. Visible regardless of
  2200. tab/layout so the user can clear the gate without hunting for
  2201. skipped items. Hidden entirely when no gates are active. */}
  2202. {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
  2203. <div className="mb-4 space-y-2">
  2204. {gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
  2205. <div
  2206. key={printerId}
  2207. className="flex items-center gap-3 px-4 py-3 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg"
  2208. >
  2209. <AlertCircle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0" />
  2210. <div className="flex-1 min-w-0">
  2211. <div className="text-sm text-orange-800 dark:text-orange-200">
  2212. {t('queue.resumeAfterFailure.banner', {
  2213. printer: printerName,
  2214. count: skippedCount,
  2215. })}
  2216. </div>
  2217. <div className="text-xs text-orange-800/80 dark:text-orange-200/70 mt-0.5">
  2218. {t('queue.resumeAfterFailure.bannerHint')}
  2219. </div>
  2220. </div>
  2221. <button
  2222. onClick={() =>
  2223. setResumeConfirm({ printerId, printerName, skippedCount })
  2224. }
  2225. className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-100 dark:bg-orange-500/20 hover:bg-orange-200 dark:hover:bg-orange-500/30 text-orange-900 dark:text-orange-100 text-sm rounded-md border border-orange-300 dark:border-orange-500/40 transition-colors flex-shrink-0"
  2226. >
  2227. <PlayCircle className="w-4 h-4" />
  2228. {t('queue.resumeAfterFailure.button')}
  2229. </button>
  2230. </div>
  2231. ))}
  2232. </div>
  2233. )}
  2234. {/* Filters — about the print queue items (printer / status / location).
  2235. The Pipelines tab has its own pipeline + status filters inside the
  2236. dashboard, so this row is hidden when that tab is active. */}
  2237. {activeTab !== 'pipelines' && (
  2238. <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
  2239. <select
  2240. 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"
  2241. value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
  2242. onChange={(e) => {
  2243. const val = e.target.value;
  2244. if (val === 'unassigned') setFilterPrinter(-1);
  2245. else if (val === '') setFilterPrinter(null);
  2246. else setFilterPrinter(Number(val));
  2247. }}
  2248. >
  2249. <option value="">{t('queue.filter.allPrinters')}</option>
  2250. <option value="unassigned">{t('queue.filter.unassigned')}</option>
  2251. {printers?.map((p) => (
  2252. <option key={p.id} value={p.id}>{p.name}</option>
  2253. ))}
  2254. </select>
  2255. <select
  2256. 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"
  2257. value={filterStatus}
  2258. onChange={(e) => setFilterStatus(e.target.value)}
  2259. >
  2260. <option value="">{t('queue.filter.allStatus')}</option>
  2261. <option value="pending">{t('queue.status.pending')}</option>
  2262. <option value="printing">{t('queue.status.printing')}</option>
  2263. <option value="completed">{t('queue.status.completed')}</option>
  2264. <option value="failed">{t('queue.status.failed')}</option>
  2265. <option value="skipped">{t('queue.status.skipped')}</option>
  2266. <option value="cancelled">{t('queue.status.cancelled')}</option>
  2267. </select>
  2268. {uniqueLocations.length > 0 && (
  2269. <select
  2270. 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"
  2271. value={filterLocation}
  2272. onChange={(e) => setFilterLocation(e.target.value)}
  2273. >
  2274. <option value="">{t('queue.filter.allLocations')}</option>
  2275. {uniqueLocations.map((loc) => (
  2276. <option key={loc} value={loc}>{loc}</option>
  2277. ))}
  2278. </select>
  2279. )}
  2280. <div className="hidden sm:block flex-1" />
  2281. {activeTab === 'history' && historyItems.length > 0 && (
  2282. <Button
  2283. className="w-full sm:w-auto"
  2284. variant="secondary"
  2285. size="sm"
  2286. onClick={() => setShowClearHistoryConfirm(true)}
  2287. disabled={!hasPermission('queue:delete_all')}
  2288. title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
  2289. >
  2290. <Trash2 className="w-4 h-4" />
  2291. {t('queue.clearHistory')}
  2292. </Button>
  2293. )}
  2294. </div>
  2295. )}
  2296. {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
  2297. Hidden on History/Timeline tabs since they don't apply. */}
  2298. {activeTab === 'queue' && (
  2299. <div className="flex flex-wrap items-center gap-3 mb-6">
  2300. <div className="inline-flex items-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-0.5">
  2301. <button
  2302. className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
  2303. activeLayout === 'position' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  2304. }`}
  2305. onClick={() => setActiveLayout('position')}
  2306. title={t('queue.layout.flatList')}
  2307. >
  2308. <List className="w-3.5 h-3.5" />
  2309. <span className="hidden sm:inline">{t('queue.layout.flatList')}</span>
  2310. </button>
  2311. <button
  2312. className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
  2313. activeLayout === 'printer' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
  2314. }`}
  2315. onClick={() => setActiveLayout('printer')}
  2316. title={t('queue.layout.groupByPrinter')}
  2317. >
  2318. <Printer className="w-3.5 h-3.5" />
  2319. <span className="hidden sm:inline">{t('queue.layout.byPrinter')}</span>
  2320. </button>
  2321. </div>
  2322. <button
  2323. onClick={() => {
  2324. const newValue = !(settings?.queue_shortest_first ?? false);
  2325. sjfMutation.mutate(newValue);
  2326. }}
  2327. className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${
  2328. settings?.queue_shortest_first
  2329. ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
  2330. : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
  2331. }`}
  2332. title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}
  2333. >
  2334. <Snail className="w-4 h-4" />
  2335. <span className="hidden sm:inline">{t('queue.sjf.label', 'SJF')}</span>
  2336. <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />
  2337. </button>
  2338. </div>
  2339. )}
  2340. {/* Pipelines tab short-circuits before the queue-empty branch so the
  2341. dashboard renders even when the regular queue is empty. */}
  2342. {activeTab === 'pipelines' ? (
  2343. <PipelineRunsView />
  2344. ) : isLoading ? (
  2345. <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
  2346. ) : queue?.length === 0 ? (
  2347. <Card className="p-12 text-center border-dashed">
  2348. <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
  2349. <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
  2350. <p className="text-bambu-gray max-w-md mx-auto">
  2351. {t('queue.empty.description')}
  2352. </p>
  2353. </Card>
  2354. ) : activeTab === 'timeline' ? (
  2355. <QueueTimelineView
  2356. queueItems={queue || []}
  2357. printers={printers || []}
  2358. printerStatuses={printerStatusMap}
  2359. onItemClick={(item) => {
  2360. if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {
  2361. setRequeueItem(item);
  2362. } else if (item.status === 'pending') {
  2363. setEditItem(item);
  2364. } else if (item.status === 'printing') {
  2365. setConfirmAction({ type: 'stop', item });
  2366. }
  2367. }}
  2368. t={t}
  2369. />
  2370. ) : activeTab === 'history' ? (
  2371. <HistorySection
  2372. items={historyItems}
  2373. collapsed={false}
  2374. visibleCount={historyVisibleCount}
  2375. onShowMore={() => setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)}
  2376. sortBy={historySortBy}
  2377. sortAsc={historySortAsc}
  2378. onSortByChange={setHistorySortBy}
  2379. onSortAscToggle={() => setHistorySortAsc(!historySortAsc)}
  2380. onRemove={(item) => setConfirmAction({ type: 'remove', item })}
  2381. onRequeue={setRequeueItem}
  2382. timeFormat={timeFormat}
  2383. batchCollapsed={batchCollapsed}
  2384. toggleBatchCollapsed={toggleBatchCollapsed}
  2385. hasPermission={hasPermission}
  2386. canModify={canModify}
  2387. t={t}
  2388. />
  2389. ) : (
  2390. <div className="space-y-6 sm:space-y-8">
  2391. {/* Active Prints */}
  2392. {activeItems.length > 0 && (
  2393. <div>
  2394. <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
  2395. <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
  2396. {t('queue.sections.currentlyPrinting')}
  2397. </h2>
  2398. <div className="space-y-2 sm:space-y-3">
  2399. {activeItems.map((item) => (
  2400. <SortableQueueItem
  2401. key={item.id}
  2402. item={item}
  2403. onEdit={() => {}}
  2404. onCancel={() => {}}
  2405. onRemove={() => {}}
  2406. onStop={() => setConfirmAction({ type: 'stop', item })}
  2407. onRequeue={() => {}}
  2408. onStart={() => {}}
  2409. timeFormat={timeFormat}
  2410. hasPermission={hasPermission}
  2411. canModify={canModify}
  2412. printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
  2413. t={t}
  2414. />
  2415. ))}
  2416. </div>
  2417. </div>
  2418. )}
  2419. {/* Pending Queue */}
  2420. {pendingItems.length > 0 && (
  2421. <div>
  2422. <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
  2423. <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
  2424. <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400" />
  2425. {t('queue.sections.queued')}
  2426. <span className="text-xs sm:text-sm font-normal text-bambu-gray">
  2427. ({t('queue.itemCount', { count: pendingItems.length })})
  2428. </span>
  2429. <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
  2430. {t('queue.dragToReorder')}
  2431. </span>
  2432. </h2>
  2433. <div className="flex items-center gap-2">
  2434. <select
  2435. 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"
  2436. value={pendingSortBy}
  2437. onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
  2438. >
  2439. <option value="position">{t('queue.sort.byPosition')}</option>
  2440. <option value="name">{t('queue.sort.byName')}</option>
  2441. <option value="printer">{t('queue.sort.byPrinter')}</option>
  2442. <option value="time">{t('queue.sort.bySchedule')}</option>
  2443. </select>
  2444. <Button
  2445. variant="ghost"
  2446. size="sm"
  2447. onClick={() => setPendingSortAsc(!pendingSortAsc)}
  2448. title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
  2449. className="px-2"
  2450. >
  2451. {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
  2452. </Button>
  2453. </div>
  2454. </div>
  2455. {/* Bulk action toolbar (now with "Group as batch") */}
  2456. <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">
  2457. <Button
  2458. variant="ghost"
  2459. size="sm"
  2460. onClick={handleSelectAll}
  2461. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
  2462. >
  2463. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
  2464. <CheckSquare className="w-4 h-4 text-bambu-green" />
  2465. ) : (
  2466. <Square className="w-4 h-4" />
  2467. )}
  2468. {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
  2469. </Button>
  2470. {selectedItems.length > 0 && (
  2471. <>
  2472. <span className="text-xs sm:text-sm text-bambu-gray">
  2473. {t('queue.bulkEdit.selected', { count: selectedItems.length })}
  2474. </span>
  2475. <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
  2476. {canGroupSelected && (
  2477. <Button
  2478. variant="ghost"
  2479. size="sm"
  2480. onClick={() => setGroupBatchModal(true)}
  2481. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-cyan-700 dark:text-cyan-300 hover:text-cyan-900 dark:hover:text-cyan-200"
  2482. title={t('queue.batch.groupAsBatch')}
  2483. >
  2484. <Package className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  2485. <span className="hidden sm:inline">{t('queue.batch.groupAsBatch')}</span>
  2486. </Button>
  2487. )}
  2488. <Button
  2489. variant="ghost"
  2490. size="sm"
  2491. onClick={() => setShowBulkEditModal(true)}
  2492. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
  2493. disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
  2494. title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
  2495. >
  2496. <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  2497. <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
  2498. </Button>
  2499. <Button
  2500. variant="ghost"
  2501. size="sm"
  2502. onClick={() => bulkCancelMutation.mutate(selectedItems)}
  2503. className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
  2504. disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
  2505. title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
  2506. >
  2507. <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
  2508. <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
  2509. </Button>
  2510. </>
  2511. )}
  2512. </div>
  2513. <DndContext
  2514. sensors={sensors}
  2515. collisionDetection={closestCenter}
  2516. onDragStart={handleDragStart}
  2517. onDragEnd={handleDragEnd}
  2518. >
  2519. <SortableContext
  2520. items={sortableIds}
  2521. strategy={verticalListSortingStrategy}
  2522. >
  2523. {activeLayout === 'position' ? (
  2524. <div className="space-y-2 sm:space-y-3">
  2525. {groupedRows.map((row, idx) => (
  2526. <QueueRowRender
  2527. key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
  2528. row={row}
  2529. collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
  2530. onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
  2531. onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
  2532. setEditItem={setEditItem}
  2533. setConfirmAction={setConfirmAction}
  2534. startMutation={startMutation}
  2535. selectedItems={selectedItems}
  2536. handleToggleSelect={handleToggleSelect}
  2537. timeFormat={timeFormat}
  2538. hasPermission={hasPermission}
  2539. canModify={canModify}
  2540. t={t}
  2541. etaEligibleIds={etaEligibleIds}
  2542. etaNow={etaNow}
  2543. aggregateForRows={aggregateForRows}
  2544. {...rowMovers(groupedRows, idx)}
  2545. onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
  2546. />
  2547. ))}
  2548. </div>
  2549. ) : (
  2550. <div className="space-y-4">
  2551. {printerBuckets.map((bucket) => {
  2552. const agg = aggregateForRows(bucket.rows);
  2553. return (
  2554. <div key={bucket.key}>
  2555. <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-t-lg">
  2556. <Printer className={`w-4 h-4 ${bucket.isUnassigned ? 'text-orange-600 dark:text-orange-400' : bucket.targetModel ? 'text-blue-600 dark:text-blue-400' : 'text-bambu-green'}`} />
  2557. <span className="font-semibold text-white text-sm">{bucket.label}</span>
  2558. <span className="text-xs text-bambu-gray flex flex-wrap gap-x-3">
  2559. <span>{t('queue.itemCount', { count: agg.count })}</span>
  2560. {agg.time > 0 && <span>{formatDuration(agg.time)}</span>}
  2561. {agg.weight > 0 && <span>{formatWeight(agg.weight)}</span>}
  2562. </span>
  2563. </div>
  2564. <div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
  2565. {bucket.rows.map((row, idx) => (
  2566. <QueueRowRender
  2567. key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
  2568. row={row}
  2569. collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
  2570. onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
  2571. onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
  2572. setEditItem={setEditItem}
  2573. setConfirmAction={setConfirmAction}
  2574. startMutation={startMutation}
  2575. selectedItems={selectedItems}
  2576. handleToggleSelect={handleToggleSelect}
  2577. timeFormat={timeFormat}
  2578. hasPermission={hasPermission}
  2579. canModify={canModify}
  2580. t={t}
  2581. etaEligibleIds={etaEligibleIds}
  2582. etaNow={etaNow}
  2583. aggregateForRows={aggregateForRows}
  2584. {...rowMovers(bucket.rows, idx)}
  2585. onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
  2586. />
  2587. ))}
  2588. </div>
  2589. </div>
  2590. );
  2591. })}
  2592. </div>
  2593. )}
  2594. </SortableContext>
  2595. <DragOverlay>
  2596. {(() => {
  2597. if (activeDragId === null) return null;
  2598. // Batch drag — show the group ghost with copy count.
  2599. if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) {
  2600. const batchId = Number(activeDragId.slice('batch-'.length));
  2601. const siblings = pendingItems.filter((i) => i.batch_id === batchId);
  2602. if (siblings.length === 0) return null;
  2603. const name = siblings[0].batch_name || t('queue.batch.defaultName');
  2604. return (
  2605. <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
  2606. <Package className="w-4 h-4 text-cyan-700 dark:text-cyan-300" />
  2607. <span className="text-sm text-white font-medium">
  2608. {t('queue.dragGhost.batch', {
  2609. defaultValue: '{{name}} ({{count}} copies)',
  2610. name,
  2611. count: siblings.length,
  2612. })}
  2613. </span>
  2614. </div>
  2615. );
  2616. }
  2617. // Multi-row drag — show the N-item ghost.
  2618. if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) {
  2619. return (
  2620. <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
  2621. <Package className="w-4 h-4 text-cyan-700 dark:text-cyan-300" />
  2622. <span className="text-sm text-white font-medium">
  2623. {t('queue.dragGhost.multiCount', { count: selectedItems.length })}
  2624. </span>
  2625. </div>
  2626. );
  2627. }
  2628. return null;
  2629. })()}
  2630. </DragOverlay>
  2631. </DndContext>
  2632. </div>
  2633. )}
  2634. </div>
  2635. )}
  2636. {/* Edit Modal */}
  2637. {editItem && (
  2638. <PrintModal
  2639. mode="edit-queue-item"
  2640. archiveId={editItem.archive_id ?? undefined}
  2641. libraryFileId={editItem.library_file_id ?? undefined}
  2642. archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
  2643. queueItem={editItem}
  2644. onClose={() => setEditItem(null)}
  2645. />
  2646. )}
  2647. {/* Re-queue Modal */}
  2648. {requeueItem && (
  2649. <PrintModal
  2650. mode="create"
  2651. archiveId={requeueItem.archive_id ?? undefined}
  2652. libraryFileId={requeueItem.library_file_id ?? undefined}
  2653. archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
  2654. onClose={() => setRequeueItem(null)}
  2655. />
  2656. )}
  2657. {/* Confirm Action Modal */}
  2658. {filamentShortConfirm && (
  2659. <ConfirmModal
  2660. title={t('queue.filamentShort.confirmTitle')}
  2661. message={
  2662. t('queue.filamentShort.confirmIntro') + '\n\n' +
  2663. filamentShortConfirm.deficit
  2664. .map((d) =>
  2665. t('queue.filamentShort.lineItem', {
  2666. slot: d.slot_id,
  2667. required: Math.round(d.required_grams),
  2668. remaining:
  2669. d.remaining_grams == null
  2670. ? t('queue.filamentShort.unknown')
  2671. : Math.round(d.remaining_grams),
  2672. }),
  2673. )
  2674. .join('\n')
  2675. }
  2676. confirmText={t('queue.filamentShort.printAnyway')}
  2677. variant="warning"
  2678. onConfirm={() => {
  2679. startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true });
  2680. }}
  2681. onCancel={() => setFilamentShortConfirm(null)}
  2682. />
  2683. )}
  2684. {confirmAction && (
  2685. <ConfirmModal
  2686. title={
  2687. confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
  2688. confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
  2689. t('queue.confirm.removeTitle')
  2690. }
  2691. message={
  2692. confirmAction.type === 'cancel'
  2693. ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  2694. : confirmAction.type === 'stop'
  2695. ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
  2696. : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
  2697. }
  2698. confirmText={
  2699. confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
  2700. confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
  2701. t('common.remove')
  2702. }
  2703. variant="danger"
  2704. onConfirm={() => {
  2705. if (confirmAction.type === 'cancel') {
  2706. cancelMutation.mutate(confirmAction.item.id);
  2707. } else if (confirmAction.type === 'stop') {
  2708. stopMutation.mutate(confirmAction.item.id);
  2709. } else {
  2710. removeMutation.mutate(confirmAction.item.id);
  2711. }
  2712. setConfirmAction(null);
  2713. }}
  2714. onCancel={() => setConfirmAction(null)}
  2715. />
  2716. )}
  2717. {/* #1818: Resume-after-failure confirm */}
  2718. {resumeConfirm && (
  2719. <ConfirmModal
  2720. title={t('queue.resumeAfterFailure.confirmTitle')}
  2721. message={t('queue.resumeAfterFailure.confirmMessage', {
  2722. printer: resumeConfirm.printerName,
  2723. count: resumeConfirm.skippedCount,
  2724. })}
  2725. confirmText={t('queue.resumeAfterFailure.button')}
  2726. variant="warning"
  2727. onConfirm={() => resumeAfterFailureMutation.mutate(resumeConfirm.printerId)}
  2728. onCancel={() => setResumeConfirm(null)}
  2729. />
  2730. )}
  2731. {/* Clear History Confirm Modal */}
  2732. {showClearHistoryConfirm && (
  2733. <ConfirmModal
  2734. title={t('queue.confirm.clearHistoryTitle')}
  2735. message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
  2736. confirmText={t('queue.clearHistory')}
  2737. variant="danger"
  2738. onConfirm={() => {
  2739. clearHistoryMutation.mutate();
  2740. setShowClearHistoryConfirm(false);
  2741. }}
  2742. onCancel={() => setShowClearHistoryConfirm(false)}
  2743. />
  2744. )}
  2745. {/* Bulk Edit Modal */}
  2746. {showBulkEditModal && (
  2747. <BulkEditModal
  2748. selectedCount={selectedItems.length}
  2749. printers={printers?.map(p => ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []}
  2750. onSave={(data) => {
  2751. if (Object.keys(data).length > 0) {
  2752. bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
  2753. }
  2754. }}
  2755. onClose={() => setShowBulkEditModal(false)}
  2756. isSaving={bulkUpdateMutation.isPending}
  2757. canControlPrinter={hasPermission('printers:control')}
  2758. t={t}
  2759. />
  2760. )}
  2761. {/* Group as batch modal — name prompt */}
  2762. {groupBatchModal && (
  2763. <GroupBatchModal
  2764. itemCount={selectedItems.length}
  2765. isSaving={createBatchMutation.isPending}
  2766. defaultName={(() => {
  2767. // Suggest a name derived from the first selected item's source.
  2768. const first = pendingItems.find((i) => selectedItems.includes(i.id));
  2769. const raw = first?.archive_name || first?.library_file_name || '';
  2770. const cleaned = raw.replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
  2771. return cleaned ? `${cleaned}` : t('queue.batch.defaultName');
  2772. })()}
  2773. onSave={(name) => createBatchMutation.mutate({ name, item_ids: selectedItems })}
  2774. onClose={() => setGroupBatchModal(false)}
  2775. t={t}
  2776. />
  2777. )}
  2778. {/* Ungroup batch confirm */}
  2779. {ungroupBatchId !== null && (
  2780. <ConfirmModal
  2781. title={t('queue.batch.ungroupConfirmTitle')}
  2782. message={t('queue.batch.ungroupConfirmMessage')}
  2783. confirmText={t('queue.batch.ungroup')}
  2784. variant="warning"
  2785. onConfirm={() => ungroupBatchMutation.mutate(ungroupBatchId)}
  2786. onCancel={() => setUngroupBatchId(null)}
  2787. />
  2788. )}
  2789. </div>
  2790. );
  2791. }
  2792. interface GroupBatchModalProps {
  2793. itemCount: number;
  2794. defaultName: string;
  2795. isSaving: boolean;
  2796. onSave: (name: string) => void;
  2797. onClose: () => void;
  2798. t: (key: string, options?: Record<string, unknown>) => string;
  2799. }
  2800. function GroupBatchModal({ itemCount, defaultName, isSaving, onSave, onClose, t }: GroupBatchModalProps) {
  2801. const [name, setName] = useState(defaultName);
  2802. return (
  2803. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
  2804. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 w-full max-w-md">
  2805. <h3 className="text-lg font-semibold text-white mb-2 flex items-center gap-2">
  2806. <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
  2807. {t('queue.batch.groupAsBatch')}
  2808. </h3>
  2809. <p className="text-sm text-bambu-gray mb-4">
  2810. {t('queue.batch.groupAsBatchDescription', { count: itemCount })}
  2811. </p>
  2812. <label className="block text-sm font-medium text-white mb-2">
  2813. {t('queue.batch.nameLabel')}
  2814. </label>
  2815. <input
  2816. type="text"
  2817. autoFocus
  2818. value={name}
  2819. onChange={(e) => setName(e.target.value)}
  2820. placeholder={t('queue.batch.namePlaceholder')}
  2821. maxLength={120}
  2822. 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"
  2823. />
  2824. <div className="flex justify-end gap-2">
  2825. <Button variant="ghost" size="sm" onClick={onClose} disabled={isSaving}>
  2826. {t('common.cancel')}
  2827. </Button>
  2828. <Button
  2829. variant="primary"
  2830. size="sm"
  2831. onClick={() => {
  2832. const trimmed = name.trim();
  2833. if (trimmed) onSave(trimmed);
  2834. }}
  2835. disabled={isSaving || !name.trim()}
  2836. >
  2837. {isSaving ? t('common.saving') : t('queue.batch.create')}
  2838. </Button>
  2839. </div>
  2840. </div>
  2841. </div>
  2842. );
  2843. }