QueuePage.tsx 150 KB

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