QueuePage.tsx 121 KB

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