QueuePage.tsx 108 KB

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