QueuePage.tsx 129 KB

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