| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036 |
- import { useState, useMemo, useEffect, useCallback } from 'react';
- import { useTranslation } from 'react-i18next';
- import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
- import { Link } from 'react-router-dom';
- import {
- DndContext,
- DragOverlay,
- closestCenter,
- KeyboardSensor,
- PointerSensor,
- useSensor,
- useSensors,
- } from '@dnd-kit/core';
- import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
- import {
- SortableContext,
- sortableKeyboardCoordinates,
- useSortable,
- verticalListSortingStrategy,
- } from '@dnd-kit/sortable';
- import { CSS } from '@dnd-kit/utilities';
- import { queueItemDisplayName } from '../utils/queueItemName';
- import {
- Clock,
- Trash2,
- Play,
- X,
- CheckCircle,
- XCircle,
- AlertCircle,
- Calendar,
- Printer,
- GripVertical,
- SkipForward,
- ExternalLink,
- Power,
- StopCircle,
- Pencil,
- RefreshCw,
- Timer,
- ListOrdered,
- Layers,
- ArrowUp,
- ArrowDown,
- Hand,
- Check,
- CheckSquare,
- Square,
- User,
- Pause,
- Weight,
- ChevronDown,
- ChevronRight,
- ChevronUp,
- List,
- GanttChart,
- Code,
- Snail,
- Package,
- PackageOpen,
- Ungroup,
- Ban,
- PlayCircle,
- Workflow,
- } from 'lucide-react';
- import { api, ApiError } from '../api/client';
- import { PipelineRunsView } from './PipelineRunsPage';
- import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
- import { getBedTypeInfo } from '../utils/bedType';
- import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
- import { Card } from '../components/Card';
- import { Button } from '../components/Button';
- import { ConfirmModal } from '../components/ConfirmModal';
- import { PrintModal } from '../components/PrintModal';
- import { useToast } from '../contexts/ToastContext';
- import { useAuth } from '../contexts/AuthContext';
- import { QueueStatsBar } from '../components/QueueStatsBar';
- import { CompactHistoryRow } from '../components/CompactHistoryRow';
- import { QueueTimelineView } from '../components/QueueTimelineView';
- import { BatchOrdersView } from '../components/BatchOrdersView';
- function formatWeight(g: number, useKg = false): string {
- if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
- return `${Math.round(g)}g`;
- }
- function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
- // Special case: pending with waiting_reason shows as "Waiting"
- if (status === 'pending' && waitingReason) {
- return (
- <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-700 dark:text-purple-400 bg-purple-50 dark:bg-purple-400/10 border-purple-200 dark:border-purple-400/20">
- <Clock className="w-3.5 h-3.5" />
- {t('queue.status.waiting')}
- </span>
- );
- }
- // Special case: printing but printer is paused
- if (status === 'printing' && printerState === 'PAUSE') {
- return (
- <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-400/10 border-yellow-200 dark:border-yellow-400/20">
- <Pause className="w-3.5 h-3.5" />
- {t('queue.status.paused')}
- </span>
- );
- }
- const config = {
- pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
- printing: { icon: Play, color: 'text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 border-blue-200 dark:border-blue-400/20', label: t('queue.status.printing') },
- completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
- failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
- skipped: { icon: SkipForward, color: 'text-orange-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10 border-orange-200 dark:border-orange-400/20', label: t('queue.status.skipped') },
- cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
- };
- const { icon: Icon, color, label } = config[status];
- return (
- <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
- <Icon className="w-3.5 h-3.5" />
- {label}
- </span>
- );
- }
- // Bulk edit modal for multiple queue items
- function BulkEditModal({
- selectedCount,
- printers,
- onSave,
- onClose,
- isSaving,
- canControlPrinter,
- t,
- }: {
- selectedCount: number;
- printers: { id: number; name: string; nozzle_count?: number }[];
- onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
- onClose: () => void;
- isSaving: boolean;
- canControlPrinter: boolean;
- t: (key: string, options?: Record<string, unknown>) => string;
- }) {
- const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
- const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
- const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
- const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
- const [bedLevelling, setBedLevelling] = useState<CalibrationMode | 'unchanged'>('unchanged');
- const [flowCali, setFlowCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
- const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
- const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
- const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
- const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
- const [nozzleOffsetCali, setNozzleOffsetCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
- // Show the dual-nozzle-only toggle when the user has at least one
- // dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle
- // queues never see it — the MQTT layer ignores the field anyway.
- const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2);
- const handleSave = () => {
- const data: Partial<PrintQueueBulkUpdate> = {};
- if (printerId !== 'unchanged') data.printer_id = printerId;
- if (manualStart !== 'unchanged') data.manual_start = manualStart;
- if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
- if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
- if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
- if (flowCali !== 'unchanged') data.flow_cali = flowCali;
- if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
- if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
- if (timelapse !== 'unchanged') data.timelapse = timelapse;
- if (useAms !== 'unchanged') data.use_ams = useAms;
- if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
- onSave(data);
- };
- const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
- requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
- vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
- nozzleOffsetCali !== 'unchanged';
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
- <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <h2 className="text-lg font-semibold text-white">
- {t('queue.bulkEdit.title', { count: selectedCount })}
- </h2>
- <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
- <X className="w-5 h-5 text-bambu-gray" />
- </button>
- </div>
- <div className="p-4 space-y-4">
- <p className="text-sm text-bambu-gray">
- {t('queue.bulkEdit.description')}
- </p>
- {/* Printer Assignment */}
- <div>
- <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
- <select
- value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
- onChange={(e) => {
- const val = e.target.value;
- if (val === 'unchanged') setPrinterId('unchanged');
- else if (val === 'null') setPrinterId(null);
- else setPrinterId(Number(val));
- }}
- 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"
- >
- <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
- <option value="null">{t('queue.filter.unassigned')}</option>
- {printers.map(p => (
- <option key={p.id} value={p.id}>{p.name}</option>
- ))}
- </select>
- </div>
- {/* Queue Options */}
- <div>
- <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
- <div className="space-y-2">
- <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
- </div>
- </div>
- {/* Print Options */}
- <div>
- <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
- <div className="space-y-2">
- <CalibrationModeToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
- <CalibrationModeToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
- <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
- {hasDualNozzlePrinter && (
- <CalibrationModeToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
- )}
- </div>
- </div>
- </div>
- <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
- <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
- <Button
- onClick={handleSave}
- disabled={!hasChanges || isSaving}
- >
- {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
- </Button>
- </div>
- </div>
- </div>
- );
- }
- // Tri-state toggle for bulk edit (unchanged / on / off)
- function TriStateToggle({
- label,
- value,
- onChange,
- disabled,
- t,
- }: {
- label: string;
- value: boolean | 'unchanged';
- onChange: (val: boolean | 'unchanged') => void;
- disabled?: boolean;
- t: (key: string) => string;
- }) {
- return (
- <div className={`flex items-center justify-between py-1 ${disabled ? 'opacity-50' : ''}`}>
- <span className="text-sm text-bambu-gray">{label}</span>
- <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
- <button
- onClick={() => onChange('unchanged')}
- disabled={disabled}
- 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`}
- >
- —
- </button>
- <button
- onClick={() => onChange(false)}
- disabled={disabled}
- className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}
- >
- {t('common.off')}
- </button>
- <button
- onClick={() => onChange(true)}
- disabled={disabled}
- 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`}
- >
- {t('common.on')}
- </button>
- </div>
- </div>
- );
- }
- // Four-state selector for the tri-state calibration options in bulk edit
- // (unchanged / off / auto / on). Mirrors TriStateToggle's chrome.
- function CalibrationModeToggle({
- label,
- value,
- onChange,
- t,
- }: {
- label: string;
- value: CalibrationMode | 'unchanged';
- onChange: (val: CalibrationMode | 'unchanged') => void;
- t: (key: string) => string;
- }) {
- const modes: Array<{ key: CalibrationMode | 'unchanged'; label: string; active: string }> = [
- { key: 'unchanged', label: '—', active: 'bg-bambu-dark-tertiary text-white' },
- { key: 'off', label: t('settings.calibrationMode_off'), active: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' },
- { key: 'auto', label: t('settings.calibrationMode_auto'), active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' },
- { key: 'on', label: t('settings.calibrationMode_on'), active: 'bg-bambu-green/20 text-bambu-green' },
- ];
- return (
- <div className="flex items-center justify-between py-1">
- <span className="text-sm text-bambu-gray">{label}</span>
- <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
- {modes.map(({ key, label: modeLabel, active }) => (
- <button
- key={key}
- onClick={() => onChange(key)}
- className={`px-2 py-1 text-xs rounded ${value === key ? active : 'text-bambu-gray hover:text-white'}`}
- >
- {modeLabel}
- </button>
- ))}
- </div>
- </div>
- );
- }
- // Sortable queue item for drag and drop
- function SortableQueueItem({
- item,
- position,
- onEdit,
- onCancel,
- onRemove,
- onStop,
- onRequeue,
- onStart,
- onMoveUp,
- onMoveDown,
- timeFormat = 'system',
- isSelected = false,
- onToggleSelect,
- hasPermission,
- canModify,
- printerState,
- showEta = false,
- etaNow,
- t,
- }: {
- item: PrintQueueItem;
- position?: number;
- onEdit: () => void;
- onCancel: () => void;
- onRemove: () => void;
- onStop: () => void;
- onRequeue: () => void;
- onStart: () => void;
- // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button
- // shown disabled) or reordering isn't available; the desktop drag handle
- // is unaffected. Move one step among siblings, then persist via reorder.
- onMoveUp?: () => void;
- onMoveDown?: () => void;
- timeFormat?: TimeFormat;
- isSelected?: boolean;
- onToggleSelect?: () => void;
- hasPermission: (permission: Permission) => boolean;
- canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
- printerState?: string | null;
- // Whether this item qualifies for an "if started now" ETA (#2740), and the
- // instant to measure it from. Both are decided by the page so every row on
- // screen quotes the same clock.
- showEta?: boolean;
- etaNow?: number;
- t: (key: string, options?: Record<string, unknown>) => string;
- }) {
- // Fetch printer status every 30 seconds while printing to monitor progress
- const { data: status } = useQuery({
- queryKey: ['printerStatus', item.printer_id],
- queryFn: () => api.getPrinterStatus(item.printer_id!),
- refetchInterval: 30000,
- enabled: item.printer_id != null && printerState === 'printing',
- });
- // Determine if we're printing a library file
- const isLibraryFile = !!item.library_file_id && !item.archive_id;
- // Fetch archive plate details. Skip when the linked archive has been
- // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
- // /plates endpoint just 404-storms the queue page.
- const { data: archivePlatesData } = useQuery({
- queryKey: ['archive-plates', item.archive_id],
- queryFn: () => api.getArchivePlates(item.archive_id!),
- enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
- });
- // Fetch library file plate details
- const { data: libraryPlatesData } = useQuery({
- queryKey: ['library-file-plates', item.library_file_id],
- queryFn: () => api.getLibraryFilePlates(item.library_file_id!),
- enabled: isLibraryFile && !!item.library_file_id,
- });
- // Combine plates data from either source
- const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
- const plates = platesData?.plates ?? [];
- const canReorder = hasPermission('queue:reorder');
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- };
- const isPrinting = item.status === 'printing';
- const isPending = item.status === 'pending';
- const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
- // This is an "if started now" estimate, not a cumulative queue forecast, so
- // it is only shown for items the page determined could actually start now
- // (see etaEligibleIds). etaNow is the caller's ticking clock — deriving the
- // ETA from it rather than from Date.now() keeps this render deterministic and
- // stops the value freezing at first paint.
- const queueItemEta =
- isPending && showEta && item.print_time_seconds != null && item.print_time_seconds > 0
- ? formatETA(item.print_time_seconds / 60, timeFormat, t, etaNow)
- : null;
- const isMobileSelectable = isPending && onToggleSelect;
- return (
- <div
- ref={setNodeRef}
- style={style}
- className={`
- group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
- border-l-[3px] ${
- isPrinting ? 'border-l-blue-500' :
- isPending ? 'border-l-yellow-500' :
- item.status === 'completed' ? 'border-l-emerald-500' :
- item.status === 'failed' ? 'border-l-red-500' :
- 'border-l-gray-500'
- }
- ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
- ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
- ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
- ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
- ${isMobileSelectable ? 'sm:cursor-default' : ''}
- `}
- onClick={isMobileSelectable ? () => {
- if (window.innerWidth < 640) onToggleSelect();
- } : undefined}
- >
- {/* Mobile selected left accent bar */}
- {isMobileSelectable && isSelected && (
- <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
- )}
- <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
- {/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on
- phones and touch-drag is unreliable there, so pending rows get
- tap-to-move up/down controls instead. Shown only below `sm`. */}
- {isPending && (onMoveUp || onMoveDown) && (
- <div
- className="flex sm:hidden flex-col shrink-0 -my-1"
- onClick={(e) => e.stopPropagation()}
- >
- <button
- type="button"
- onClick={onMoveUp}
- disabled={!onMoveUp}
- title={t('queue.moveUp')}
- aria-label={t('queue.moveUp')}
- className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
- >
- <ChevronUp className="w-5 h-5" />
- </button>
- <button
- type="button"
- onClick={onMoveDown}
- disabled={!onMoveDown}
- title={t('queue.moveDown')}
- aria-label={t('queue.moveDown')}
- className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
- >
- <ChevronDown className="w-5 h-5" />
- </button>
- </div>
- )}
- {/* Mobile selection indicator — left accent bar only, no tick */}
- {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
- {isPending && onToggleSelect && (
- <button
- onClick={(e) => {
- e.stopPropagation();
- onToggleSelect();
- }}
- className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
- isSelected
- ? 'bg-bambu-green border-bambu-green text-white'
- : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
- }`}
- >
- {isSelected && <Check className="w-4 h-4" />}
- </button>
- )}
- {/* Drag handle or position number - hidden on mobile */}
- {isPending ? (
- <div
- {...attributes}
- {...listeners}
- className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
- >
- <GripVertical className="w-4 h-4 text-bambu-gray" />
- </div>
- ) : position !== undefined ? (
- <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">
- #{position}
- </div>
- ) : (
- <div className="hidden sm:block w-8 shrink-0" />
- )}
- {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
- <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
- {item.archive_thumbnail ? (
- <img
- src={
- item.plate_id != null
- ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
- : api.getArchiveThumbnail(item.archive_id!)
- }
- alt=""
- className="w-full h-full object-cover"
- />
- ) : item.library_file_thumbnail ? (
- <img
- src={
- item.plate_id != null
- ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
- : api.getLibraryFileThumbnailUrl(item.library_file_id!)
- }
- alt=""
- className="w-full h-full object-cover"
- />
- ) : (
- <div className="w-full h-full flex items-center justify-center text-bambu-gray">
- <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
- </div>
- )}
- </div>
- {/* Info */}
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-1">
- <p className="text-sm sm:text-base text-white font-medium truncate">
- {queueItemDisplayName(item, (n) => t('common.plusNMore', { count: n }))}
- {(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 })}`}
- </p>
- {item.archive_id ? (
- <Link
- to={`/archives?highlight=${item.archive_id}`}
- className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
- title={t('queue.viewArchive')}
- >
- <ExternalLink className="w-3.5 h-3.5" />
- </Link>
- ) : item.library_file_id ? (
- <Link
- to={`/library?highlight=${item.library_file_id}`}
- className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
- title={t('queue.viewInFileManager')}
- >
- <ExternalLink className="w-3.5 h-3.5" />
- </Link>
- ) : null}
- {item.batch_name && (
- <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 rounded border border-cyan-300 dark:border-cyan-500/30">
- {item.batch_name}
- </span>
- )}
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
- <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-700 dark:text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-700 dark:text-blue-400' : ''}`}>
- <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- <span className="truncate max-w-[120px] sm:max-w-none">
- {/* A cross-model item (#671) is waiting on several models at once.
- Showing only target_model would name whichever candidate is
- first and read as a lie the moment the other one runs. */}
- {(item.variants?.length ?? 0) > 1 && !item.printer_id
- ? `${t('queue.filter.any')} ${item.variants!.map(v => v.target_model).join(' / ')}${item.target_location ? ` @ ${item.target_location}` : ''}`
- : item.target_model && !item.printer_id
- ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
- : item.printer_id === null
- ? t('queue.filter.unassigned')
- : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
- </span>
- </span>
- {item.print_time_seconds && (
- <span className="flex items-center gap-1 sm:gap-1.5">
- <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- {formatDuration(item.print_time_seconds)}
- </span>
- )}
- {queueItemEta && (
- <span
- data-testid="queue-item-eta"
- className="text-bambu-green font-medium"
- title={t('queue.time.etaIfStartedNow')}
- >
- ETA {queueItemEta}
- </span>
- )}
- {item.filament_used_grams && (
- <span className="flex items-center gap-1 sm:gap-1.5">
- <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- {formatWeight(item.filament_used_grams)}
- </span>
- )}
- {(() => {
- // Build plate badge so the user knows which plate to mount before
- // walking to the printer (#1281). Hidden when the 3MF doesn't
- // carry curr_bed_type or the slicer used an unknown label.
- const bed = getBedTypeInfo(item.bed_type);
- if (!bed) return null;
- return (
- <span className="flex items-center gap-1 sm:gap-1.5" title={bed.label}>
- <img src={bed.icon} alt="" className="w-3.5 h-3.5 sm:w-4 sm:h-4 object-contain" />
- <span className="truncate max-w-[120px]">{bed.label}</span>
- </span>
- );
- })()}
- {item.created_by_username && (
- <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
- <User className="w-3.5 h-3.5" />
- {item.created_by_username}
- </span>
- )}
- {isPending && !item.manual_start && (
- <span className="flex items-center gap-1.5">
- <Clock className="w-3.5 h-3.5" />
- {item.scheduled_time
- ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
- ? t?.('queue.time.overdue') ?? 'Overdue'
- : formatRelativeTime(item.scheduled_time, timeFormat, t))
- : t?.('queue.time.asap') ?? 'ASAP'}
- </span>
- )}
- </div>
- {/* Options badges */}
- <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
- {item.manual_start && (
- <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 rounded-full border border-purple-200 dark:border-purple-500/20 flex items-center gap-1">
- <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
- {t('queue.badges.staged')}
- </span>
- )}
- {item.require_previous_success && (
- <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-50 dark:bg-orange-500/10 text-orange-700 dark:text-orange-400 rounded-full border border-orange-200 dark:border-orange-500/20">
- {t('queue.badges.requiresPrevious')}
- </span>
- )}
- {item.auto_off_after && (
- <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded-full border border-blue-200 dark:border-blue-500/20 flex items-center gap-1">
- <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
- {t('queue.badges.autoPowerOff')}
- </span>
- )}
- {item.gcode_injection && (
- <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-50 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 rounded-full border border-emerald-200 dark:border-emerald-500/20 flex items-center gap-1">
- <Code className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
- {t('queue.badges.gcodeInjection')}
- </span>
- )}
- </div>
- {/* Progress bar for printing items - TODO: integrate with WebSocket */}
- {isPrinting && status && (() => {
- // Gate progress/remaining/layer on printer actually running this print.
- // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress
- // is stale from the previous print — showing 100% then snapping back to 0%
- // once the new print starts. Only trust these fields when state is active.
- const isActive = status.state === 'RUNNING' || status.state === 'PAUSE';
- const progress = isActive ? (status.progress || 0) : 0;
- const remaining = isActive ? status.remaining_time : null;
- const layerNum = isActive ? status.layer_num : null;
- const totalLayers = isActive ? status.total_layers : null;
- return (
- <div className="mt-2 sm:mt-3">
- <div className="flex items-center justify-between text-xs sm:text-sm">
- <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
- <div
- className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
- style={{ width: `${progress}%` }}
- />
- </div>
- <span className="text-white">{Math.round(progress)}%</span>
- </div>
- <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">
- {remaining != null && remaining > 0 && (
- <>
- <span className="flex items-center gap-1">
- <Clock className="w-3 h-3" />
- {formatDuration(remaining * 60)}
- </span>
- <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
- ETA {formatETA(remaining, timeFormat, t)}
- </span>
- </>
- )}
- {layerNum != null && totalLayers != null && totalLayers > 0 && (
- <span className="flex items-center gap-1">
- <Layers className="w-3 h-3" />
- {layerNum}/{totalLayers}
- </span>
- )}
- </div>
- </div>
- );
- })()}
- {/* Waiting reason for model-based assignments */}
- {item.waiting_reason && item.status === 'pending' && (
- <p className="text-[10px] sm:text-xs text-purple-700 dark:text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
- <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
- <span>{item.waiting_reason}</span>
- </p>
- )}
- {/* Filament-short flag from the dispatch pre-flight (#1496). */}
- {item.filament_short && item.status === 'pending' && (
- <p
- className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-400 mt-1.5 sm:mt-2 flex items-start gap-1"
- title={t('queue.filamentShort.rowTooltip')}
- >
- <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
- <span>{t('queue.filamentShort.rowBadge')}</span>
- </p>
- )}
- {/* Archive carries the slicer's own live-resolved AMS-slot pick
- (extra_data.slicer_ams_mapping) — reprints of this archive reuse
- the exact physical spool instead of re-deriving one. */}
- {item.archive_has_slicer_ams_mapping && (
- <p
- className="text-[10px] sm:text-xs text-green-700 dark:text-green-400 mt-1.5 sm:mt-2 flex items-start gap-1"
- title={t('queue.slicerAmsMapping.rowTooltip')}
- >
- <Check className="w-3 h-3 mt-0.5 flex-shrink-0" />
- <span>{t('queue.slicerAmsMapping.rowBadge')}</span>
- </p>
- )}
- {/* Error message */}
- {item.error_message && (
- <p className="text-[10px] sm:text-xs text-red-700 dark:text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
- <AlertCircle className="w-3 h-3" />
- {item.error_message}
- </p>
- )}
- </div>
- {/* Status badge + Actions */}
- <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
- <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
- <div className="flex items-center gap-0.5 sm:gap-1">
- {isPrinting && (
- <Button
- variant="ghost"
- size="sm"
- onClick={onStop}
- disabled={!canModify('queue', 'update', item.created_by_id)}
- title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
- className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10 p-1.5 sm:p-2"
- >
- <StopCircle className="w-4 h-4" />
- </Button>
- )}
- {isPending && (
- <>
- {item.manual_start && (
- <Button
- variant="ghost"
- size="sm"
- onClick={onStart}
- disabled={!canModify('queue', 'update', item.created_by_id)}
- title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
- className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
- >
- <Play className="w-4 h-4" />
- </Button>
- )}
- <Button
- variant="ghost"
- size="sm"
- onClick={onEdit}
- disabled={!canModify('queue', 'update', item.created_by_id)}
- title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
- className="p-1.5 sm:p-2"
- >
- <Pencil className="w-4 h-4" />
- </Button>
- <Button
- variant="ghost"
- size="sm"
- onClick={onCancel}
- disabled={!canModify('queue', 'delete', item.created_by_id)}
- title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
- className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10 p-1.5 sm:p-2"
- >
- <X className="w-4 h-4" />
- </Button>
- </>
- )}
- {isHistory && (
- <>
- <Button
- variant="ghost"
- size="sm"
- onClick={onRequeue}
- disabled={!hasPermission('queue:create')}
- title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
- className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
- >
- <RefreshCw className="w-4 h-4" />
- </Button>
- <Button
- variant="ghost"
- size="sm"
- onClick={onRemove}
- disabled={!canModify('queue', 'delete', item.created_by_id)}
- title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
- className="p-1.5 sm:p-2"
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </>
- )}
- </div>
- </div>
- </div>
- </div>
- );
- }
- type QueueRow =
- | { kind: 'item'; item: PrintQueueItem }
- | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
- interface QueueRowRenderProps {
- row: QueueRow;
- collapsed: boolean;
- onToggleBatch?: () => void;
- onUngroup?: () => void;
- setEditItem: (item: PrintQueueItem) => void;
- setConfirmAction: (a: { type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem }) => void;
- startMutation: { mutate: (vars: { id: number; skipFilamentCheck?: boolean }) => void };
- selectedItems: number[];
- handleToggleSelect: (id: number) => void;
- timeFormat: TimeFormat;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- hasPermission: (p: any) => boolean;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- canModify: (resource: any, action: any, createdById?: number | null) => boolean;
- t: (key: string, options?: Record<string, unknown>) => string;
- // Items that qualify for an "if started now" ETA, and the shared clock it is
- // measured from (#2740).
- etaEligibleIds: Set<number>;
- etaNow: number;
- aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
- // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
- // (single item or batch) one step among its siblings; onMoveBlock is the
- // low-level primitive SortableBatchRow uses to move a child within the
- // batch. All undefined when reordering isn't available (non-manual sort).
- onMoveUp?: () => void;
- onMoveDown?: () => void;
- onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void;
- }
- /** Renders either a single item or a collapsible batch group containing N
- * sibling items. The batch parent shows aggregate stats; children render
- * with the existing SortableQueueItem (only draggable inside the batch). */
- function QueueRowRender(props: QueueRowRenderProps) {
- const {
- row,
- setEditItem,
- setConfirmAction,
- startMutation,
- selectedItems,
- handleToggleSelect,
- timeFormat,
- hasPermission,
- canModify,
- t,
- etaEligibleIds,
- etaNow,
- onMoveUp,
- onMoveDown,
- } = props;
- if (row.kind === 'item') {
- return (
- <SortableQueueItem
- item={row.item}
- onEdit={() => setEditItem(row.item)}
- onCancel={() => setConfirmAction({ type: 'cancel', item: row.item })}
- onRemove={() => {}}
- onStop={() => {}}
- onRequeue={() => {}}
- onStart={() => startMutation.mutate({ id: row.item.id })}
- onMoveUp={onMoveUp}
- onMoveDown={onMoveDown}
- timeFormat={timeFormat}
- isSelected={selectedItems.includes(row.item.id)}
- onToggleSelect={() => handleToggleSelect(row.item.id)}
- hasPermission={hasPermission}
- canModify={canModify}
- showEta={etaEligibleIds.has(row.item.id)}
- etaNow={etaNow}
- t={t}
- />
- );
- }
- return <SortableBatchRow {...props} />;
- }
- /** Batch parent header registered with dnd-kit so the whole group can be
- * reordered as one unit. Drag handle lives in the header itself; children
- * remain individually draggable while expanded for within-batch reorder. */
- function SortableBatchRow({
- row,
- collapsed,
- onToggleBatch,
- onUngroup,
- setEditItem,
- setConfirmAction,
- startMutation,
- selectedItems,
- handleToggleSelect,
- timeFormat,
- hasPermission,
- canModify,
- t,
- etaEligibleIds,
- etaNow,
- aggregateForRows,
- onMoveUp,
- onMoveDown,
- onMoveBlock,
- }: QueueRowRenderProps) {
- // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
- // narrow up-front so the hook below can reference batchId unconditionally.
- const batchRow = row as Extract<QueueRow, { kind: 'batch' }>;
- const canReorder = hasPermission('queue:reorder');
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder });
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- };
- const agg = aggregateForRows([batchRow]);
- const allChildIds = batchRow.items.map((i) => i.id);
- const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id));
- // Status rollup: worst-of-children (failed > printing > pending).
- const childStatuses = new Set(batchRow.items.map((i) => i.status));
- // We never put non-pending into a batch grouping but render defensively.
- const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed')
- ? 'failed'
- : childStatuses.has('printing')
- ? 'printing'
- : 'pending';
- const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length;
- return (
- <div
- ref={setNodeRef}
- style={style}
- className={`bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden ${
- isDragging ? 'opacity-50 scale-[1.01] shadow-xl z-50' : ''
- }`}
- >
- {/* Parent header */}
- <div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
- {/* Mobile reorder arrows for the whole group (#2667), mirroring the
- desktop drag handle which is hidden on phones. */}
- {canReorder && (onMoveUp || onMoveDown) && (
- <div className="flex sm:hidden flex-col shrink-0 -my-1">
- <button
- type="button"
- onClick={onMoveUp}
- disabled={!onMoveUp}
- title={t('queue.moveUp')}
- aria-label={t('queue.moveUp')}
- className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
- >
- <ChevronUp className="w-5 h-5" />
- </button>
- <button
- type="button"
- onClick={onMoveDown}
- disabled={!onMoveDown}
- title={t('queue.moveDown')}
- aria-label={t('queue.moveDown')}
- className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
- >
- <ChevronDown className="w-5 h-5" />
- </button>
- </div>
- )}
- <button
- onClick={(e) => {
- e.stopPropagation();
- allChildIds.forEach((id) => {
- if (allSelected && selectedItems.includes(id)) {
- handleToggleSelect(id);
- } else if (!allSelected && !selectedItems.includes(id)) {
- handleToggleSelect(id);
- }
- });
- }}
- className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
- allSelected
- ? 'bg-bambu-green border-bambu-green text-white'
- : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
- }`}
- title={allSelected ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
- >
- {allSelected && <Check className="w-4 h-4" />}
- </button>
- {canReorder && (
- <div
- {...attributes}
- {...listeners}
- className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
- title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
- >
- <GripVertical className="w-4 h-4 text-bambu-gray" />
- </div>
- )}
- <button
- onClick={onToggleBatch}
- className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
- title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
- >
- {collapsed ? (
- <ChevronRight className="w-4 h-4 text-bambu-gray" />
- ) : (
- <ChevronDown className="w-4 h-4 text-bambu-gray" />
- )}
- </button>
- <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">
- {collapsed ? (
- <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
- ) : (
- <PackageOpen className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
- )}
- </div>
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2 mb-1">
- <p className="text-sm sm:text-base text-white font-medium truncate">{batchRow.batchName}</p>
- <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-100 dark:bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 rounded border border-cyan-300 dark:border-cyan-500/30">
- {t('queue.batch.label', { count: agg.count })}
- </span>
- </div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
- {agg.time > 0 && (
- <span className="flex items-center gap-1 sm:gap-1.5">
- <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- {formatDuration(agg.time)}
- </span>
- )}
- {agg.weight > 0 && (
- <span className="flex items-center gap-1 sm:gap-1.5">
- <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- {formatWeight(agg.weight)}
- </span>
- )}
- {pendingChildren > 0 && rollupStatus === 'pending' && (
- <span className="flex items-center gap-1 sm:gap-1.5 text-yellow-700 dark:text-yellow-400">
- <Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- {t('queue.batch.pendingCount', { count: pendingChildren })}
- </span>
- )}
- </div>
- </div>
- <div className="flex items-center gap-1 shrink-0">
- {onUngroup && (
- <Button
- variant="ghost"
- size="sm"
- onClick={onUngroup}
- title={t('queue.batch.ungroup')}
- className="text-cyan-700 dark:text-cyan-300 hover:text-cyan-900 dark:hover:text-cyan-200 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 p-1.5 sm:p-2"
- >
- <Ungroup className="w-4 h-4" />
- </Button>
- )}
- </div>
- </div>
- {/* Children (only when expanded) */}
- {!collapsed && (
- <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
- {batchRow.items.map((child, ci) => (
- <SortableQueueItem
- key={child.id}
- item={child}
- onEdit={() => setEditItem(child)}
- onCancel={() => setConfirmAction({ type: 'cancel', item: child })}
- onRemove={() => {}}
- onStop={() => {}}
- onRequeue={() => {}}
- onStart={() => startMutation.mutate({ id: child.id })}
- onMoveUp={
- onMoveBlock && ci > 0
- ? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false)
- : undefined
- }
- onMoveDown={
- onMoveBlock && ci < batchRow.items.length - 1
- ? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true)
- : undefined
- }
- timeFormat={timeFormat}
- isSelected={selectedItems.includes(child.id)}
- onToggleSelect={() => handleToggleSelect(child.id)}
- hasPermission={hasPermission}
- canModify={canModify}
- showEta={etaEligibleIds.has(child.id)}
- etaNow={etaNow}
- t={t}
- />
- ))}
- </div>
- )}
- </div>
- );
- }
- type HistoryRow =
- | { kind: 'item'; item: PrintQueueItem }
- | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
- interface HistorySectionProps {
- items: PrintQueueItem[];
- collapsed: boolean;
- visibleCount: number;
- onShowMore: () => void;
- sortBy: 'date' | 'name' | 'printer';
- sortAsc: boolean;
- onSortByChange: (v: 'date' | 'name' | 'printer') => void;
- onSortAscToggle: () => void;
- onRemove: (item: PrintQueueItem) => void;
- onRequeue: (item: PrintQueueItem) => void;
- timeFormat: TimeFormat;
- batchCollapsed: Record<number, boolean>;
- toggleBatchCollapsed: (id: number) => void;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- hasPermission: (p: any) => boolean;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- canModify: (resource: any, action: any, createdById?: number | null) => boolean;
- t: (key: string, options?: Record<string, unknown>) => string;
- }
- function HistorySection({
- items,
- visibleCount,
- onShowMore,
- sortBy,
- sortAsc,
- onSortByChange,
- onSortAscToggle,
- onRemove,
- onRequeue,
- timeFormat,
- batchCollapsed,
- toggleBatchCollapsed,
- hasPermission,
- canModify,
- t,
- }: HistorySectionProps) {
- if (items.length === 0) {
- return (
- <Card className="p-12 text-center border-dashed">
- <ListOrdered className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
- <h3 className="text-xl font-medium text-white mb-2">{t('queue.history.emptyTitle')}</h3>
- <p className="text-bambu-gray max-w-md mx-auto">{t('queue.history.emptyDescription')}</p>
- </Card>
- );
- }
- // Group siblings sharing a batch_id into a single collapsible row.
- // First-seen order is preserved for batches; items keep their sorted
- // position from the parent's sort selector.
- const rows: HistoryRow[] = [];
- const seenBatches = new Set<number>();
- for (const item of items.slice(0, visibleCount)) {
- if (item.batch_id != null) {
- if (seenBatches.has(item.batch_id)) continue;
- seenBatches.add(item.batch_id);
- const siblings = items.filter((s) => s.batch_id === item.batch_id);
- rows.push({
- kind: 'batch',
- batchId: item.batch_id,
- batchName: item.batch_name || t('queue.batch.defaultName'),
- items: siblings,
- });
- } else {
- rows.push({ kind: 'item', item });
- }
- }
- return (
- <div>
- <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
- <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
- {t('queue.sections.history')}
- <span className="text-xs sm:text-sm font-normal text-bambu-gray">
- ({t('queue.itemCount', { count: items.length })})
- </span>
- </h2>
- <div className="flex items-center gap-2">
- <select
- 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"
- value={sortBy}
- onChange={(e) => onSortByChange(e.target.value as 'date' | 'name' | 'printer')}
- >
- <option value="date">{t('queue.sort.byDate')}</option>
- <option value="name">{t('queue.sort.byName')}</option>
- <option value="printer">{t('queue.sort.byPrinter')}</option>
- </select>
- <Button
- variant="ghost"
- size="sm"
- onClick={onSortAscToggle}
- title={sortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
- className="px-2"
- >
- {sortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
- </Button>
- </div>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
- {rows.map((row) => {
- if (row.kind === 'item') {
- return (
- <CompactHistoryRow
- key={row.item.id}
- item={row.item}
- onRemove={() => onRemove(row.item)}
- onRequeue={() => onRequeue(row.item)}
- timeFormat={timeFormat}
- hasPermission={hasPermission}
- canModify={canModify}
- t={t}
- />
- );
- }
- // Batch group — spans the full grid width so it visually anchors
- // its children below it. The children themselves render in the
- // same responsive grid pattern inside the expanded body.
- const collapsed = batchCollapsed[row.batchId] ?? true;
- const completed = row.items.filter((i) => i.status === 'completed').length;
- const failed = row.items.filter((i) => i.status === 'failed').length;
- const skipped = row.items.filter((i) => i.status === 'skipped').length;
- const cancelled = row.items.filter((i) => i.status === 'cancelled').length;
- const latest = row.items
- .map((i) => i.completed_at || i.created_at)
- .filter((v): v is string => !!v)
- .sort()
- .at(-1);
- return (
- <div
- key={`batch-${row.batchId}`}
- 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"
- >
- <button
- onClick={() => toggleBatchCollapsed(row.batchId)}
- className="w-full flex items-center gap-2 sm:gap-3 px-3 py-2 hover:bg-bambu-dark/30 transition-colors text-left"
- title={collapsed ? t('queue.batch.expand') : t('queue.batch.collapse')}
- >
- {collapsed ? (
- <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
- ) : (
- <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
- )}
- {collapsed ? (
- <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300 shrink-0" />
- ) : (
- <PackageOpen className="w-5 h-5 text-cyan-700 dark:text-cyan-300 shrink-0" />
- )}
- <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
- {row.batchName}
- </span>
- <div className="flex items-center gap-2 text-xs text-bambu-gray shrink-0">
- {completed > 0 && (
- <span className="flex items-center gap-1 text-emerald-700 dark:text-emerald-400">
- <CheckCircle className="w-3 h-3" />
- {completed}
- </span>
- )}
- {failed > 0 && (
- <span className="flex items-center gap-1 text-red-700 dark:text-red-400">
- <XCircle className="w-3 h-3" />
- {failed}
- </span>
- )}
- {skipped > 0 && (
- <span className="flex items-center gap-1 text-orange-700 dark:text-orange-400">
- <SkipForward className="w-3 h-3" />
- {skipped}
- </span>
- )}
- {cancelled > 0 && (
- <span className="flex items-center gap-1 text-gray-400">
- <Ban className="w-3 h-3" />
- {cancelled}
- </span>
- )}
- <span title={latest ?? undefined}>
- {latest ? formatRelativeTime(latest, timeFormat, t) : null}
- </span>
- </div>
- </button>
- {!collapsed && (
- <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">
- {row.items.map((child) => (
- <CompactHistoryRow
- key={child.id}
- item={child}
- onRemove={() => onRemove(child)}
- onRequeue={() => onRequeue(child)}
- timeFormat={timeFormat}
- hasPermission={hasPermission}
- canModify={canModify}
- t={t}
- />
- ))}
- </div>
- )}
- </div>
- );
- })}
- </div>
- {items.length > visibleCount && (
- <div className="mt-4 flex flex-col items-center gap-2">
- <Button variant="secondary" size="sm" onClick={onShowMore}>
- {t('queue.history.showMore')}
- </Button>
- <span className="text-xs text-bambu-gray">
- {t('queue.history.showingCount', {
- shown: Math.min(visibleCount, items.length),
- total: items.length,
- })}
- </span>
- </div>
- )}
- </div>
- );
- }
- const HISTORY_PAGE_SIZE = 50;
- export function QueuePage() {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const { hasPermission, hasAnyPermission, canModify } = useAuth();
- const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
- const [filterStatus, setFilterStatus] = useState<string>('');
- const [filterLocation, setFilterLocation] = useState<string>('');
- const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
- const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
- const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
- const [confirmAction, setConfirmAction] = useState<{
- type: 'cancel' | 'remove' | 'stop';
- item: PrintQueueItem;
- } | null>(null);
- const [selectedItems, setSelectedItems] = useState<number[]>([]);
- const [showBulkEditModal, setShowBulkEditModal] = useState(false);
- // #1818: per-printer Resume-after-failure confirm modal. Tracks which
- // printer's gate the user is about to clear; null when no modal is open.
- const [resumeConfirm, setResumeConfirm] = useState<{
- printerId: number;
- printerName: string;
- skippedCount: number;
- } | null>(null);
- const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
- const saved = localStorage.getItem('queue.historySortBy');
- return (saved as 'date' | 'name' | 'printer') || 'date';
- });
- const [historySortAsc, setHistorySortAsc] = useState(() => {
- const saved = localStorage.getItem('queue.historySortAsc');
- return saved !== null ? saved === 'true' : false;
- });
- // #2682: History renders progressively — start at one page, grow on demand.
- // Reset happens only on a deliberate re-sort / filter change (below), NOT on
- // the periodic queue poll, so an expanded view doesn't snap back mid-scroll.
- const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
- const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
- const saved = localStorage.getItem('queue.pendingSortBy');
- return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
- });
- const [pendingSortAsc, setPendingSortAsc] = useState(() => {
- const saved = localStorage.getItem('queue.pendingSortAsc');
- return saved !== null ? saved === 'true' : true;
- });
- // historyCollapsed legacy state retained only for localStorage migration; the
- // History tab renders unconditionally so this no longer drives the UI.
- // Tabbed page structure: Active queue stays as the main view; History
- // and Timeline split off. Persists per-user via localStorage.
- const [activeTab, setActiveTab] = useState<'queue' | 'batches' | 'history' | 'timeline' | 'pipelines'>(() => {
- // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
- // right tab. localStorage holds the per-user last-selected fallback.
- const search = new URLSearchParams(window.location.search);
- const url = search.get('tab');
- if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue' || url === 'batches') {
- return url;
- }
- const saved = localStorage.getItem('queue.activeTab');
- if (saved === 'history' || saved === 'timeline' || saved === 'pipelines' || saved === 'batches') return saved;
- return 'queue';
- });
- // Active-tab layout toggle. "position" = today's flat list; "printer"
- // groups items under per-printer section headers with aggregate stats.
- const [activeLayout, setActiveLayout] = useState<'position' | 'printer'>(() => {
- const saved = localStorage.getItem('queue.activeLayout');
- return saved === 'printer' ? 'printer' : 'position';
- });
- // Per-batch collapse state, keyed by batch_id. Default = collapsed
- // (matches the SimplyPrint/Files convention — show the rollup first).
- const [batchCollapsed, setBatchCollapsed] = useState<Record<number, boolean>>(() => {
- try {
- const saved = localStorage.getItem('queue.batchCollapsed');
- return saved ? JSON.parse(saved) : {};
- } catch {
- return {};
- }
- });
- // Multi-drag bookkeeping for DragOverlay. Numeric for single items, string
- // `batch-<id>` when a whole group is being dragged.
- const [activeDragId, setActiveDragId] = useState<number | string | null>(null);
- // "Group as batch" modal.
- const [groupBatchModal, setGroupBatchModal] = useState(false);
- // Ungroup confirm.
- const [ungroupBatchId, setUngroupBatchId] = useState<number | null>(null);
- // Persist sort settings to localStorage
- useEffect(() => {
- localStorage.setItem('queue.historySortBy', historySortBy);
- }, [historySortBy]);
- useEffect(() => {
- localStorage.setItem('queue.historySortAsc', String(historySortAsc));
- }, [historySortAsc]);
- // Collapse History back to a single page when the user re-sorts or changes
- // the location filter (deliberate view changes). Intentionally excludes the
- // queue poll so periodic refetches keep the expanded count.
- useEffect(() => {
- setHistoryVisibleCount(HISTORY_PAGE_SIZE);
- }, [historySortBy, historySortAsc, filterLocation]);
- useEffect(() => {
- localStorage.setItem('queue.pendingSortBy', pendingSortBy);
- }, [pendingSortBy]);
- useEffect(() => {
- localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
- }, [pendingSortAsc]);
- useEffect(() => {
- localStorage.setItem('queue.activeTab', activeTab);
- }, [activeTab]);
- useEffect(() => {
- localStorage.setItem('queue.activeLayout', activeLayout);
- }, [activeLayout]);
- useEffect(() => {
- localStorage.setItem('queue.batchCollapsed', JSON.stringify(batchCollapsed));
- }, [batchCollapsed]);
- const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
- );
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: api.getSettings,
- });
- const timeFormat: TimeFormat = settings?.time_format || 'system';
- // Badge count for the Batches tab (#342). Deliberately its own query rather
- // than derived from the queue: an order whose runs have all finished has no
- // queue rows left, and those are precisely the orders the tab exists to
- // surface. Shares the ['batches'] key with the tab itself, so dispatching or
- // cancelling refreshes both.
- const { data: activeBatches } = useQuery({
- queryKey: ['batches', 'active'],
- queryFn: () => api.getBatches('active'),
- });
- const activeBatchCount = activeBatches?.length ?? 0;
- const { data: queue, isLoading } = useQuery({
- queryKey: ['queue', filterPrinter, filterStatus],
- queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
- refetchInterval: 5000,
- });
- const { data: printers } = useQuery({
- queryKey: ['printers'],
- queryFn: () => api.getPrinters(),
- });
- const sjfMutation = useMutation({
- mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['settings'] });
- },
- });
- const cancelMutation = useMutation({
- mutationFn: (id: number) => api.cancelQueueItem(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- showToast(t('queue.toast.cancelled'));
- },
- onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
- });
- const removeMutation = useMutation({
- mutationFn: (id: number) => api.removeFromQueue(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- showToast(t('queue.toast.removed'));
- },
- onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
- });
- const stopMutation = useMutation({
- mutationFn: (id: number) => api.stopQueueItem(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- showToast(t('queue.toast.stopped'));
- },
- onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
- });
- // Filament-deficit confirmation state (#1496). When the backend returns
- // 409 with `code=insufficient_filament` we stash the deficit + item id
- // here; the modal at the bottom of the page reads it and the "Print
- // Anyway" path re-issues the start with `skipFilamentCheck=true`.
- const [filamentShortConfirm, setFilamentShortConfirm] = useState<{
- itemId: number;
- deficit: Array<{
- slot_id: number;
- required_grams: number;
- remaining_grams: number | null;
- filament_type?: string | null;
- }>;
- } | null>(null);
- const startMutation = useMutation({
- mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
- api.startQueueItem(id, { skipFilamentCheck }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- showToast(t('queue.toast.released'));
- setFilamentShortConfirm(null);
- },
- onError: (error: unknown, variables) => {
- if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
- const deficitRaw = (error.detail?.deficit ?? []) as Array<{
- slot_id: number;
- required_grams: number;
- remaining_grams: number | null;
- filament_type?: string | null;
- }>;
- setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
- return;
- }
- showToast(t('queue.toast.startFailed'), 'error');
- },
- });
- const reorderMutation = useMutation({
- mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- },
- onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
- });
- const clearHistoryMutation = useMutation({
- mutationFn: async () => {
- const historyItems = queue?.filter(i =>
- ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
- ) || [];
- for (const item of historyItems) {
- await api.removeFromQueue(item.id);
- }
- return historyItems.length;
- },
- onSuccess: (count) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- showToast(t('queue.toast.historyCleared', { count }));
- },
- onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
- });
- const bulkUpdateMutation = useMutation({
- mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
- onSuccess: (result) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- setSelectedItems([]);
- setShowBulkEditModal(false);
- showToast(result.message);
- },
- onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
- });
- const bulkCancelMutation = useMutation({
- mutationFn: async (ids: number[]) => {
- for (const id of ids) {
- await api.cancelQueueItem(id);
- }
- return ids.length;
- },
- onSuccess: (count) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- setSelectedItems([]);
- showToast(t('queue.toast.bulkCancelled', { count }));
- },
- onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
- });
- const resumeAfterFailureMutation = useMutation({
- mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId),
- onSuccess: (result) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- setResumeConfirm(null);
- showToast(
- t('queue.toast.resumedAfterFailure', {
- restored: result.restored,
- acknowledged: result.acknowledged,
- }),
- );
- },
- onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
- });
- const createBatchMutation = useMutation({
- mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
- onSuccess: (batch) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- setSelectedItems([]);
- setGroupBatchModal(false);
- // New batches start expanded so the user sees what they just grouped.
- setBatchCollapsed((prev) => ({ ...prev, [batch.id]: false }));
- showToast(t('queue.toast.batchCreated', { name: batch.name }));
- },
- onError: () => showToast(t('queue.toast.batchCreateFailed'), 'error'),
- });
- const ungroupBatchMutation = useMutation({
- mutationFn: (id: number) => api.ungroupBatch(id),
- onSuccess: (result) => {
- queryClient.invalidateQueries({ queryKey: ['queue'] });
- setUngroupBatchId(null);
- showToast(t('queue.toast.batchUngrouped', { count: result.ungrouped_count }));
- },
- onError: () => showToast(t('queue.toast.batchUngroupFailed'), 'error'),
- });
- const handleToggleSelect = (id: number) => {
- setSelectedItems(prev =>
- prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
- );
- };
- // Get unique locations from printers for the filter dropdown
- const uniqueLocations = useMemo(() => {
- const locations = new Set<string>();
- printers?.forEach(p => {
- if (p.location) locations.add(p.location);
- });
- // Also include locations from queue items (for model-based assignments)
- queue?.forEach(item => {
- if (item.target_location) locations.add(item.target_location);
- });
- return Array.from(locations).sort();
- }, [printers, queue]);
- // Helper to check if a queue item matches the location filter
- const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
- if (!filterLocation) return true;
- // For model-based assignments, check target_location
- if (item.target_location) return item.target_location === filterLocation;
- // For printer-based assignments, check the printer's location
- if (item.printer_id) {
- const printer = printers?.find(p => p.id === item.printer_id);
- return printer?.location === filterLocation;
- }
- return false;
- }, [filterLocation, printers]);
- const pendingItems = useMemo(() => {
- let items = queue?.filter(i => i.status === 'pending') || [];
- // Apply location filter
- if (filterLocation) {
- items = items.filter(matchesLocationFilter);
- }
- // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
- const getScheduledTime = (item: PrintQueueItem): number => {
- if (!item.scheduled_time) return 0;
- const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;
- // Placeholder dates (> 6 months out) are treated as ASAP
- const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
- return time > sixMonthsFromNow ? 0 : time;
- };
- // When SJF is enabled, override sort to match scheduler order
- if (settings?.queue_shortest_first) {
- return [...items].sort((a, b) => {
- // Group by printer first (nulls = model-based, grouped by target_model)
- const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);
- const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);
- if (aPrinter !== bPrinter) return aPrinter - bPrinter;
- // Within same printer/model: jumped items first (starvation guard)
- const aJumped = a.been_jumped ? 1 : 0;
- const bJumped = b.been_jumped ? 1 : 0;
- if (aJumped !== bJumped) return bJumped - aJumped;
- // Shortest print time next (nulls last)
- const aTime = a.print_time_seconds ?? Infinity;
- const bTime = b.print_time_seconds ?? Infinity;
- if (aTime !== bTime) return aTime - bTime;
- // Position as tiebreaker
- return a.position - b.position;
- });
- }
- return [...items].sort((a, b) => {
- let cmp: number;
- if (pendingSortBy === 'name') {
- const aName = a.archive_name || a.library_file_name || '';
- const bName = b.archive_name || b.library_file_name || '';
- cmp = aName.localeCompare(bName);
- } else if (pendingSortBy === 'printer') {
- cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
- } else if (pendingSortBy === 'time') {
- // Sort by scheduled start time (when print will begin)
- cmp = getScheduledTime(a) - getScheduledTime(b);
- } else {
- cmp = a.position - b.position;
- }
- return pendingSortAsc ? cmp : -cmp;
- });
- }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);
- const handleSelectAll = () => {
- const allPendingIds = pendingItems.map(i => i.id);
- if (selectedItems.length === allPendingIds.length) {
- setSelectedItems([]);
- } else {
- setSelectedItems(allPendingIds);
- }
- };
- const activeItems = useMemo(() => {
- let items = queue?.filter(i => i.status === 'printing') || [];
- if (filterLocation) {
- items = items.filter(matchesLocationFilter);
- }
- return items;
- }, [queue, filterLocation, matchesLocationFilter]);
- // Queue items eligible for an "if started now" ETA (#2740).
- //
- // The ETA answers "when would this finish if it began right now", so it may
- // only appear on items that really could begin right now. Deriving that from
- // waiting_reason alone is not enough: the scheduler only writes that field on
- // the model-based assignment path (print_scheduler.py), so an item pinned to a
- // specific printer sits behind a running job with waiting_reason still NULL.
- //
- // Computed from the unfiltered queue on purpose — hiding a printer behind the
- // location filter must not make its printer look free.
- const etaEligibleIds = useMemo(() => {
- const eligible = new Set<number>();
- if (!queue) return eligible;
- const busyPrinters = new Set<number>();
- queue.forEach(item => {
- if (item.status === 'printing' && item.printer_id) busyPrinters.add(item.printer_id);
- });
- const isFutureScheduled = (item: PrintQueueItem): boolean => {
- if (!item.scheduled_time) return false;
- return (parseUTCDate(item.scheduled_time)?.getTime() ?? 0) > Date.now();
- };
- // Mirrors the scheduler's own ordering so "next up" here means the item the
- // scheduler would actually dispatch next, not whatever the user sorted by.
- const schedulerOrder = (a: PrintQueueItem, b: PrintQueueItem): number => {
- if (settings?.queue_shortest_first) {
- const aJumped = a.been_jumped ? 1 : 0;
- const bJumped = b.been_jumped ? 1 : 0;
- if (aJumped !== bJumped) return bJumped - aJumped;
- const aTime = a.print_time_seconds ?? Infinity;
- const bTime = b.print_time_seconds ?? Infinity;
- if (aTime !== bTime) return aTime - bTime;
- }
- return a.position - b.position;
- };
- // Claimants for each printer, in the order the scheduler would take them.
- // Staged and future-scheduled items are excluded: the scheduler skips both
- // without marking the printer busy, so neither holds up the item behind it.
- const contenders = new Map<number, PrintQueueItem[]>();
- queue
- .filter(
- item =>
- item.status === 'pending' &&
- item.printer_id != null &&
- !item.manual_start &&
- !isFutureScheduled(item)
- )
- .sort(schedulerOrder)
- .forEach(item => {
- const list = contenders.get(item.printer_id!) ?? [];
- list.push(item);
- contenders.set(item.printer_id!, list);
- });
- queue.forEach(item => {
- if (item.status !== 'pending') return;
- // Blocked, scheduled for later, or no usable duration to add.
- if (item.waiting_reason) return;
- if (isFutureScheduled(item)) return;
- if (item.print_time_seconds == null || item.print_time_seconds <= 0) return;
- // Conditional on an earlier print's outcome, which the UI cannot see: the
- // scheduler may skip it outright rather than ever running it.
- if (item.require_previous_success) return;
- // Model-based items have no printer yet; an empty waiting_reason is the
- // scheduler saying it found one, so trust that.
- if (item.printer_id == null) {
- eligible.add(item.id);
- return;
- }
- if (busyPrinters.has(item.printer_id)) return;
- // Staged items wait on the user, not on the queue, so they are startable
- // whenever their printer is free regardless of what is queued ahead.
- if (item.manual_start) {
- eligible.add(item.id);
- return;
- }
- if (contenders.get(item.printer_id)?.[0]?.id === item.id) eligible.add(item.id);
- });
- return eligible;
- }, [queue, settings?.queue_shortest_first]);
- // The ETA is "now + duration", so it goes stale on its own. Nothing else
- // re-renders these rows while the queue payload is unchanged (react-query's
- // structural sharing keeps the reference stable), so drive it from a clock of
- // our own. Only runs while an ETA is actually on screen.
- const [etaNow, setEtaNow] = useState(() => Date.now());
- const hasEtas = etaEligibleIds.size > 0;
- useEffect(() => {
- if (!hasEtas) return;
- setEtaNow(Date.now());
- const id = setInterval(() => setEtaNow(Date.now()), 30000);
- return () => clearInterval(id);
- }, [hasEtas]);
- // Get unique printer IDs from active items to fetch their statuses
- const activePrinterIds = useMemo(() => {
- const ids = new Set<number>();
- activeItems.forEach(item => {
- if (item.printer_id) ids.add(item.printer_id);
- });
- return Array.from(ids);
- }, [activeItems]);
- // Fetch printer statuses for printers with active jobs
- const printerStatusQueries = useQueries({
- queries: activePrinterIds.map(printerId => ({
- queryKey: ['printerStatus', printerId],
- queryFn: () => api.getPrinterStatus(printerId),
- refetchInterval: 5000,
- })),
- });
- // Build a map of printer_id -> state for quick lookup
- const printerStateMap = useMemo(() => {
- const map: Record<number, string | null> = {};
- activePrinterIds.forEach((printerId, index) => {
- const result = printerStatusQueries[index];
- if (result?.data?.state) {
- map[printerId] = result.data.state;
- }
- });
- return map;
- }, [activePrinterIds, printerStatusQueries]);
- // Build a map of printer_id -> full status for timeline view
- const printerStatusMap = useMemo(() => {
- const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};
- activePrinterIds.forEach((printerId, index) => {
- const result = printerStatusQueries[index];
- if (result?.data) {
- map[printerId] = {
- progress: result.data.progress ?? undefined,
- remaining_time: result.data.remaining_time ?? undefined,
- state: result.data.state ?? undefined,
- };
- }
- });
- return map;
- }, [activePrinterIds, printerStatusQueries]);
- const historyItems = useMemo(() => {
- let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
- if (filterLocation) {
- items = items.filter(matchesLocationFilter);
- }
- return [...items].sort((a, b) => {
- let cmp: number;
- if (historySortBy === 'name') {
- const aName = a.archive_name || a.library_file_name || '';
- const bName = b.archive_name || b.library_file_name || '';
- cmp = aName.localeCompare(bName);
- } else if (historySortBy === 'printer') {
- cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
- } else {
- // Default: by date - most recent first (desc) is the natural order
- cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
- }
- return historySortAsc ? -cmp : cmp;
- });
- }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
- // Calculate total queue time
- const totalQueueTime = useMemo(() => {
- return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
- }, [pendingItems]);
- // Calculate total material weight
- const totalWeight = useMemo(() => {
- return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
- }, [pendingItems]);
- const handleDragStart = (event: DragStartEvent) => {
- const id = event.active.id;
- setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null);
- };
- const handleDragEnd = (event: DragEndEvent) => {
- const { active, over } = event;
- setActiveDragId(null);
- if (!over || active.id === over.id) return;
- // Resolve dragged source → movingIds (preserving order from pendingItems).
- // - `batch-<id>`: every child of that batch, in their current order
- // - selected + dragged is one of them: contiguous multi-drag block
- // - otherwise: single row
- let movingIds: number[];
- const activeId = active.id;
- if (typeof activeId === 'string' && activeId.startsWith('batch-')) {
- const batchId = Number(activeId.slice('batch-'.length));
- movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id);
- } else {
- const draggedId = activeId as number;
- movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
- ? selectedItems.slice().sort((a, b) => {
- const ai = pendingItems.findIndex((i) => i.id === a);
- const bi = pendingItems.findIndex((i) => i.id === b);
- return ai - bi;
- })
- : [draggedId];
- }
- if (movingIds.length === 0) return;
- // Resolve drop target → index inside pendingItems. A `batch-<id>` drop
- // target anchors at the batch's first child, so dropping above another
- // batch lands the moving block immediately before it.
- let overIndex: number;
- const overId = over.id;
- if (typeof overId === 'string' && overId.startsWith('batch-')) {
- const overBatchId = Number(overId.slice('batch-'.length));
- overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId);
- } else {
- overIndex = pendingItems.findIndex((i) => i.id === overId);
- }
- if (overIndex === -1) return;
- // Remove the moving items, then re-insert at overIndex (adjusted).
- const overAnchor = pendingItems[overIndex];
- const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
- let insertAt = remaining.findIndex((i) => i.id === overAnchor.id);
- if (insertAt === -1) insertAt = overIndex;
- // If dragging downward across the drop target, insert AFTER it; upward
- // = before. dnd-kit's `over` is the row under the pointer, not the gap.
- const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]);
- if (firstMovingIndex < overIndex) insertAt += 1;
- const reordered = [
- ...remaining.slice(0, insertAt),
- ...movingIds
- .map((id) => pendingItems.find((i) => i.id === id))
- .filter((x): x is PrintQueueItem => !!x),
- ...remaining.slice(insertAt),
- ];
- const updates = reordered.map((item, index) => ({
- id: item.id,
- position: index + 1,
- }));
- reorderMutation.mutate(updates);
- };
- // Group pending items by batch_id. Items with batch_id null render as
- // standalone rows; items sharing a batch_id render as a collapsible
- // group keyed by that id. Items inside a group keep their original
- // relative order from pendingItems.
- const groupedRows = useMemo<QueueRow[]>(() => {
- const rows: QueueRow[] = [];
- const seenBatches = new Set<number>();
- for (const item of pendingItems) {
- if (item.batch_id != null) {
- if (seenBatches.has(item.batch_id)) continue;
- seenBatches.add(item.batch_id);
- const siblings = pendingItems.filter((s) => s.batch_id === item.batch_id);
- rows.push({
- kind: 'batch',
- batchId: item.batch_id,
- batchName: item.batch_name || t('queue.batch.defaultName'),
- items: siblings,
- });
- } else {
- rows.push({ kind: 'item', item });
- }
- }
- return rows;
- }, [pendingItems, t]);
- // Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on
- // phones and touch-drag is unreliable, so pending rows get up/down arrows.
- // Reordering only has a defined meaning in the manual "position" sort with
- // SJF off — any other sort re-orders the list itself, so we don't offer it.
- const canReorderManually =
- hasPermission('queue:reorder') &&
- pendingSortBy === 'position' &&
- !settings?.queue_shortest_first;
- const rowItemIds = (row: QueueRow): number[] =>
- row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id);
- // Move a block of items to sit immediately before (or after) an anchor item
- // in the global pending order, then persist — the same remove-and-reinsert
- // shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item
- // id keeps it correct in the printer-grouped layout, where a bucket's rows
- // aren't contiguous in the global order.
- const moveBlockRelativeTo = (
- movingIds: number[],
- anchorId: number,
- placeAfter: boolean,
- ) => {
- const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
- let insertAt = remaining.findIndex((i) => i.id === anchorId);
- if (insertAt === -1) return;
- if (placeAfter) insertAt += 1;
- const movingItems = movingIds
- .map((id) => pendingItems.find((i) => i.id === id))
- .filter((x): x is PrintQueueItem => !!x);
- const reordered = [
- ...remaining.slice(0, insertAt),
- ...movingItems,
- ...remaining.slice(insertAt),
- ];
- reorderMutation.mutate(
- reordered.map((item, index) => ({ id: item.id, position: index + 1 })),
- );
- };
- // Build up/down thunks for the row at `idx` within its displayed sibling
- // list (the flat list, or a single printer bucket). Undefined at a boundary
- // (button rendered disabled) or when manual reorder isn't available.
- const rowMovers = (
- rows: QueueRow[],
- idx: number,
- ): { onMoveUp?: () => void; onMoveDown?: () => void } => {
- if (!canReorderManually) return {};
- const moving = rowItemIds(rows[idx]);
- const onMoveUp =
- idx > 0
- ? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false)
- : undefined;
- const onMoveDown =
- idx < rows.length - 1
- ? () => {
- const next = rowItemIds(rows[idx + 1]);
- moveBlockRelativeTo(moving, next[next.length - 1], true);
- }
- : undefined;
- return { onMoveUp, onMoveDown };
- };
- // SortableContext ID list.
- // - Standalone pending items: their numeric id.
- // - Batch parents: the synthetic `batch-<id>` string, always present so the
- // group itself is draggable and acts as a drop target whether collapsed
- // or expanded.
- // - Expanded batch children: their numeric id, so within-batch reorder
- // keeps working. Collapsed children are detached from the DOM and
- // intentionally omitted to keep dnd-kit's collision resolver clean.
- const sortableIds = useMemo<(number | string)[]>(() => {
- const ids: (number | string)[] = [];
- for (const row of groupedRows) {
- if (row.kind === 'item') {
- ids.push(row.item.id);
- } else {
- ids.push(`batch-${row.batchId}`);
- const collapsed = batchCollapsed[row.batchId] ?? true;
- if (!collapsed) {
- for (const child of row.items) ids.push(child.id);
- }
- }
- }
- return ids;
- }, [groupedRows, batchCollapsed]);
- // Items already in a batch can't be grouped; "Group as batch" only shows
- // when 2+ ungrouped items are selected.
- const canGroupSelected = useMemo(() => {
- if (selectedItems.length < 2) return false;
- return selectedItems.every((id) => {
- const item = pendingItems.find((p) => p.id === id);
- return item && item.batch_id == null;
- });
- }, [selectedItems, pendingItems]);
- const toggleBatchCollapsed = (id: number) => {
- setBatchCollapsed((prev) => ({ ...prev, [id]: !(prev[id] ?? true) }));
- };
- // Group by printer view. Items are bucketed by printer_id (null = model
- // assignment or unassigned, keyed by target_model or "unassigned").
- type PrinterBucket = {
- key: string;
- printerId: number | null;
- targetModel: string | null;
- label: string;
- isUnassigned: boolean;
- rows: QueueRow[];
- };
- const printerBuckets = useMemo<PrinterBucket[]>(() => {
- const buckets = new Map<string, PrinterBucket>();
- const bucketForItem = (item: PrintQueueItem): { key: string; label: string; printerId: number | null; targetModel: string | null; isUnassigned: boolean } => {
- if (item.printer_id) {
- return {
- key: `printer:${item.printer_id}`,
- label: item.printer_name || `Printer #${item.printer_id}`,
- printerId: item.printer_id,
- targetModel: null,
- isUnassigned: false,
- };
- }
- // A cross-model item (#671) is waiting on several models. Its own
- // target_model is just the first candidate mirrored onto the row, so
- // bucketing on it would file the job under one printer it might never
- // run on — and the row underneath already says "Any H2D / X1C".
- if ((item.variants?.length ?? 0) > 1) {
- const models = item.variants!.map((v) => v.target_model).join(' / ');
- return {
- key: `models:${models}`,
- label: `${t('queue.filter.any')} ${models}`,
- printerId: null,
- targetModel: null,
- isUnassigned: false,
- };
- }
- if (item.target_model) {
- return {
- key: `model:${item.target_model}`,
- label: `${t('queue.filter.any')} ${item.target_model}`,
- printerId: null,
- targetModel: item.target_model,
- isUnassigned: false,
- };
- }
- return {
- key: 'unassigned',
- label: t('queue.filter.unassigned'),
- printerId: null,
- targetModel: null,
- isUnassigned: true,
- };
- };
- for (const row of groupedRows) {
- const representative = row.kind === 'item' ? row.item : row.items[0];
- if (!representative) continue;
- const meta = bucketForItem(representative);
- if (!buckets.has(meta.key)) {
- buckets.set(meta.key, { ...meta, rows: [] });
- }
- buckets.get(meta.key)!.rows.push(row);
- }
- return Array.from(buckets.values()).sort((a, b) => {
- if (a.isUnassigned && !b.isUnassigned) return 1;
- if (!a.isUnassigned && b.isUnassigned) return -1;
- return a.label.localeCompare(b.label);
- });
- }, [groupedRows, t]);
- // #1818: printers whose queue is gated by a prior failure that's poisoning
- // downstream `require_previous_success` items. We surface a per-printer
- // Resume banner above the active queue so the user can clear the gate +
- // restore the skipped jobs in one click, without re-queuing each one.
- // Detection key: skipped + the scheduler's exact gate string. Other skip
- // reasons (filament deficit, etc.) get their own UX and stay untouched.
- const gateBlockedPrinters = useMemo<
- Array<{ printerId: number; printerName: string; skippedCount: number }>
- >(() => {
- const counts = new Map<number, { name: string; count: number }>();
- queue?.forEach((item) => {
- if (
- item.status === 'skipped' &&
- item.error_message === 'Previous print failed or was aborted' &&
- item.printer_id
- ) {
- const existing = counts.get(item.printer_id);
- if (existing) {
- existing.count += 1;
- } else {
- counts.set(item.printer_id, {
- name: item.printer_name || `Printer #${item.printer_id}`,
- count: 1,
- });
- }
- }
- });
- return Array.from(counts.entries())
- .map(([printerId, { name, count }]) => ({
- printerId,
- printerName: name,
- skippedCount: count,
- }))
- .sort((a, b) => a.printerName.localeCompare(b.printerName));
- }, [queue]);
- const aggregateForRows = (rows: QueueRow[]) => {
- let count = 0;
- let time = 0;
- let weight = 0;
- for (const row of rows) {
- const items = row.kind === 'item' ? [row.item] : row.items;
- for (const item of items) {
- count += 1;
- time += item.print_time_seconds || 0;
- weight += item.filament_used_grams || 0;
- }
- }
- return { count, time, weight };
- };
- return (
- <div className="p-4 md:p-8">
- {/* Header */}
- <div className="flex items-center justify-between mb-6">
- <div>
- <h1 className="text-2xl font-bold text-white flex items-center gap-3">
- <ListOrdered className="w-7 h-7 text-bambu-green" />
- {t('queue.title')}
- </h1>
- <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
- </div>
- </div>
- {/* Tab strip — Active queue is the main view; History and Timeline
- live in their own tabs so the queue page stays focused. */}
- <div className="flex gap-1 border-b border-bambu-dark-tertiary mb-6 overflow-x-auto">
- {([
- { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
- { id: 'batches' as const, label: t('queue.tabs.batches'), icon: Package, count: activeBatchCount },
- { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
- { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
- // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
- // its own sidebar entry so the Print Queue page is the single
- // place an operator looks for "what's running / what ran".
- { id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null },
- ]).map(({ id, label, icon: Icon, count }) => (
- <button
- key={id}
- onClick={() => setActiveTab(id)}
- className={`px-4 py-2.5 text-sm flex items-center gap-2 border-b-2 -mb-px transition-colors whitespace-nowrap ${
- activeTab === id
- ? 'text-white border-bambu-green font-medium'
- : 'text-bambu-gray border-transparent hover:text-white'
- }`}
- >
- <Icon className="w-4 h-4" />
- {label}
- {count !== null && count > 0 && (
- <span className={`text-xs px-1.5 py-0.5 rounded-full ${
- activeTab === id ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
- }`}>
- {count}
- </span>
- )}
- </button>
- ))}
- </div>
- {/* Summary Stats — about the print queue, not pipelines. */}
- {activeTab !== 'pipelines' && activeTab !== 'batches' && <QueueStatsBar
- activeCount={activeItems.length}
- pendingCount={pendingItems.length}
- totalTime={totalQueueTime}
- totalWeight={totalWeight}
- historyCount={historyItems.length}
- t={t}
- />}
- {/* #1818: Resume-after-failure banner. One row per printer whose queue
- is gated by a prior failed/aborted print. Visible regardless of
- tab/layout so the user can clear the gate without hunting for
- skipped items. Hidden entirely when no gates are active. */}
- {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
- <div className="mb-4 space-y-2">
- {gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
- <div
- key={printerId}
- className="flex items-center gap-3 px-4 py-3 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg"
- >
- <AlertCircle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0" />
- <div className="flex-1 min-w-0">
- <div className="text-sm text-orange-800 dark:text-orange-200">
- {t('queue.resumeAfterFailure.banner', {
- printer: printerName,
- count: skippedCount,
- })}
- </div>
- <div className="text-xs text-orange-800/80 dark:text-orange-200/70 mt-0.5">
- {t('queue.resumeAfterFailure.bannerHint')}
- </div>
- </div>
- <button
- onClick={() =>
- setResumeConfirm({ printerId, printerName, skippedCount })
- }
- className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-100 dark:bg-orange-500/20 hover:bg-orange-200 dark:hover:bg-orange-500/30 text-orange-900 dark:text-orange-100 text-sm rounded-md border border-orange-300 dark:border-orange-500/40 transition-colors flex-shrink-0"
- >
- <PlayCircle className="w-4 h-4" />
- {t('queue.resumeAfterFailure.button')}
- </button>
- </div>
- ))}
- </div>
- )}
- {/* Filters — about the print queue items (printer / status / location).
- The Pipelines tab has its own pipeline + status filters inside the
- dashboard, so this row is hidden when that tab is active. */}
- {activeTab !== 'pipelines' && activeTab !== 'batches' && (
- <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
- <select
- 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"
- value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
- onChange={(e) => {
- const val = e.target.value;
- if (val === 'unassigned') setFilterPrinter(-1);
- else if (val === '') setFilterPrinter(null);
- else setFilterPrinter(Number(val));
- }}
- >
- <option value="">{t('queue.filter.allPrinters')}</option>
- <option value="unassigned">{t('queue.filter.unassigned')}</option>
- {printers?.map((p) => (
- <option key={p.id} value={p.id}>{p.name}</option>
- ))}
- </select>
- <select
- 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"
- value={filterStatus}
- onChange={(e) => setFilterStatus(e.target.value)}
- >
- <option value="">{t('queue.filter.allStatus')}</option>
- <option value="pending">{t('queue.status.pending')}</option>
- <option value="printing">{t('queue.status.printing')}</option>
- <option value="completed">{t('queue.status.completed')}</option>
- <option value="failed">{t('queue.status.failed')}</option>
- <option value="skipped">{t('queue.status.skipped')}</option>
- <option value="cancelled">{t('queue.status.cancelled')}</option>
- </select>
- {uniqueLocations.length > 0 && (
- <select
- 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"
- value={filterLocation}
- onChange={(e) => setFilterLocation(e.target.value)}
- >
- <option value="">{t('queue.filter.allLocations')}</option>
- {uniqueLocations.map((loc) => (
- <option key={loc} value={loc}>{loc}</option>
- ))}
- </select>
- )}
- <div className="hidden sm:block flex-1" />
- {activeTab === 'history' && historyItems.length > 0 && (
- <Button
- className="w-full sm:w-auto"
- variant="secondary"
- size="sm"
- onClick={() => setShowClearHistoryConfirm(true)}
- disabled={!hasPermission('queue:delete_all')}
- title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
- >
- <Trash2 className="w-4 h-4" />
- {t('queue.clearHistory')}
- </Button>
- )}
- </div>
- )}
- {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
- Hidden on History/Timeline tabs since they don't apply. */}
- {activeTab === 'queue' && (
- <div className="flex flex-wrap items-center gap-3 mb-6">
- <div className="inline-flex items-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-0.5">
- <button
- className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
- activeLayout === 'position' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
- }`}
- onClick={() => setActiveLayout('position')}
- title={t('queue.layout.flatList')}
- >
- <List className="w-3.5 h-3.5" />
- <span className="hidden sm:inline">{t('queue.layout.flatList')}</span>
- </button>
- <button
- className={`px-3 py-1.5 text-xs sm:text-sm rounded-md transition-colors flex items-center gap-1.5 ${
- activeLayout === 'printer' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'
- }`}
- onClick={() => setActiveLayout('printer')}
- title={t('queue.layout.groupByPrinter')}
- >
- <Printer className="w-3.5 h-3.5" />
- <span className="hidden sm:inline">{t('queue.layout.byPrinter')}</span>
- </button>
- </div>
- <button
- onClick={() => {
- const newValue = !(settings?.queue_shortest_first ?? false);
- sjfMutation.mutate(newValue);
- }}
- className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${
- settings?.queue_shortest_first
- ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
- : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
- }`}
- title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}
- >
- <Snail className="w-4 h-4" />
- <span className="hidden sm:inline">{t('queue.sjf.label', 'SJF')}</span>
- <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />
- </button>
- </div>
- )}
- {/* Pipelines tab short-circuits before the queue-empty branch so the
- dashboard renders even when the regular queue is empty. */}
- {activeTab === 'pipelines' ? (
- <PipelineRunsView />
- ) : activeTab === 'batches' ? (
- <BatchOrdersView hasPermission={hasPermission} t={t} />
- ) : isLoading ? (
- <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
- ) : queue?.length === 0 ? (
- <Card className="p-12 text-center border-dashed">
- <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
- <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
- <p className="text-bambu-gray max-w-md mx-auto">
- {t('queue.empty.description')}
- </p>
- </Card>
- ) : activeTab === 'timeline' ? (
- <QueueTimelineView
- queueItems={queue || []}
- printers={printers || []}
- printerStatuses={printerStatusMap}
- onItemClick={(item) => {
- if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {
- setRequeueItem(item);
- } else if (item.status === 'pending') {
- setEditItem(item);
- } else if (item.status === 'printing') {
- setConfirmAction({ type: 'stop', item });
- }
- }}
- t={t}
- />
- ) : activeTab === 'history' ? (
- <HistorySection
- items={historyItems}
- collapsed={false}
- visibleCount={historyVisibleCount}
- onShowMore={() => setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)}
- sortBy={historySortBy}
- sortAsc={historySortAsc}
- onSortByChange={setHistorySortBy}
- onSortAscToggle={() => setHistorySortAsc(!historySortAsc)}
- onRemove={(item) => setConfirmAction({ type: 'remove', item })}
- onRequeue={setRequeueItem}
- timeFormat={timeFormat}
- batchCollapsed={batchCollapsed}
- toggleBatchCollapsed={toggleBatchCollapsed}
- hasPermission={hasPermission}
- canModify={canModify}
- t={t}
- />
- ) : (
- <div className="space-y-6 sm:space-y-8">
- {/* Active Prints */}
- {activeItems.length > 0 && (
- <div>
- <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
- <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
- {t('queue.sections.currentlyPrinting')}
- </h2>
- <div className="space-y-2 sm:space-y-3">
- {activeItems.map((item) => (
- <SortableQueueItem
- key={item.id}
- item={item}
- onEdit={() => {}}
- onCancel={() => {}}
- onRemove={() => {}}
- onStop={() => setConfirmAction({ type: 'stop', item })}
- onRequeue={() => {}}
- onStart={() => {}}
- timeFormat={timeFormat}
- hasPermission={hasPermission}
- canModify={canModify}
- printerState={item.printer_id ? printerStateMap[item.printer_id] : null}
- t={t}
- />
- ))}
- </div>
- </div>
- )}
- {/* Pending Queue */}
- {pendingItems.length > 0 && (
- <div>
- <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
- <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
- <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400" />
- {t('queue.sections.queued')}
- <span className="text-xs sm:text-sm font-normal text-bambu-gray">
- ({t('queue.itemCount', { count: pendingItems.length })})
- </span>
- <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
- {t('queue.dragToReorder')}
- </span>
- </h2>
- <div className="flex items-center gap-2">
- <select
- 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"
- value={pendingSortBy}
- onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
- >
- <option value="position">{t('queue.sort.byPosition')}</option>
- <option value="name">{t('queue.sort.byName')}</option>
- <option value="printer">{t('queue.sort.byPrinter')}</option>
- <option value="time">{t('queue.sort.bySchedule')}</option>
- </select>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setPendingSortAsc(!pendingSortAsc)}
- title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
- className="px-2"
- >
- {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
- </Button>
- </div>
- </div>
- {/* Bulk action toolbar (now with "Group as batch") */}
- <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">
- <Button
- variant="ghost"
- size="sm"
- onClick={handleSelectAll}
- className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
- >
- {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
- <CheckSquare className="w-4 h-4 text-bambu-green" />
- ) : (
- <Square className="w-4 h-4" />
- )}
- {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
- </Button>
- {selectedItems.length > 0 && (
- <>
- <span className="text-xs sm:text-sm text-bambu-gray">
- {t('queue.bulkEdit.selected', { count: selectedItems.length })}
- </span>
- <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
- {canGroupSelected && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setGroupBatchModal(true)}
- className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-cyan-700 dark:text-cyan-300 hover:text-cyan-900 dark:hover:text-cyan-200"
- title={t('queue.batch.groupAsBatch')}
- >
- <Package className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
- <span className="hidden sm:inline">{t('queue.batch.groupAsBatch')}</span>
- </Button>
- )}
- <Button
- variant="ghost"
- size="sm"
- onClick={() => setShowBulkEditModal(true)}
- className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
- disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
- title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
- >
- <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
- <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
- </Button>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => bulkCancelMutation.mutate(selectedItems)}
- className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
- disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
- title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
- >
- <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
- <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
- </Button>
- </>
- )}
- </div>
- <DndContext
- sensors={sensors}
- collisionDetection={closestCenter}
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- >
- <SortableContext
- items={sortableIds}
- strategy={verticalListSortingStrategy}
- >
- {activeLayout === 'position' ? (
- <div className="space-y-2 sm:space-y-3">
- {groupedRows.map((row, idx) => (
- <QueueRowRender
- key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
- row={row}
- collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
- onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
- onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
- setEditItem={setEditItem}
- setConfirmAction={setConfirmAction}
- startMutation={startMutation}
- selectedItems={selectedItems}
- handleToggleSelect={handleToggleSelect}
- timeFormat={timeFormat}
- hasPermission={hasPermission}
- canModify={canModify}
- t={t}
- etaEligibleIds={etaEligibleIds}
- etaNow={etaNow}
- aggregateForRows={aggregateForRows}
- {...rowMovers(groupedRows, idx)}
- onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
- />
- ))}
- </div>
- ) : (
- <div className="space-y-4">
- {printerBuckets.map((bucket) => {
- const agg = aggregateForRows(bucket.rows);
- return (
- <div key={bucket.key}>
- <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-t-lg">
- <Printer className={`w-4 h-4 ${bucket.isUnassigned ? 'text-orange-600 dark:text-orange-400' : bucket.targetModel ? 'text-blue-600 dark:text-blue-400' : 'text-bambu-green'}`} />
- <span className="font-semibold text-white text-sm">{bucket.label}</span>
- <span className="text-xs text-bambu-gray flex flex-wrap gap-x-3">
- <span>{t('queue.itemCount', { count: agg.count })}</span>
- {agg.time > 0 && <span>{formatDuration(agg.time)}</span>}
- {agg.weight > 0 && <span>{formatWeight(agg.weight)}</span>}
- </span>
- </div>
- <div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
- {bucket.rows.map((row, idx) => (
- <QueueRowRender
- key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
- row={row}
- collapsed={row.kind === 'batch' ? (batchCollapsed[row.batchId] ?? true) : false}
- onToggleBatch={row.kind === 'batch' ? () => toggleBatchCollapsed(row.batchId) : undefined}
- onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined}
- setEditItem={setEditItem}
- setConfirmAction={setConfirmAction}
- startMutation={startMutation}
- selectedItems={selectedItems}
- handleToggleSelect={handleToggleSelect}
- timeFormat={timeFormat}
- hasPermission={hasPermission}
- canModify={canModify}
- t={t}
- etaEligibleIds={etaEligibleIds}
- etaNow={etaNow}
- aggregateForRows={aggregateForRows}
- {...rowMovers(bucket.rows, idx)}
- onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
- />
- ))}
- </div>
- </div>
- );
- })}
- </div>
- )}
- </SortableContext>
- <DragOverlay>
- {(() => {
- if (activeDragId === null) return null;
- // Batch drag — show the group ghost with copy count.
- if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) {
- const batchId = Number(activeDragId.slice('batch-'.length));
- const siblings = pendingItems.filter((i) => i.batch_id === batchId);
- if (siblings.length === 0) return null;
- const name = siblings[0].batch_name || t('queue.batch.defaultName');
- return (
- <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
- <Package className="w-4 h-4 text-cyan-700 dark:text-cyan-300" />
- <span className="text-sm text-white font-medium">
- {t('queue.dragGhost.batch', {
- defaultValue: '{{name}} ({{count}} copies)',
- name,
- count: siblings.length,
- })}
- </span>
- </div>
- );
- }
- // Multi-row drag — show the N-item ghost.
- if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) {
- return (
- <div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
- <Package className="w-4 h-4 text-cyan-700 dark:text-cyan-300" />
- <span className="text-sm text-white font-medium">
- {t('queue.dragGhost.multiCount', { count: selectedItems.length })}
- </span>
- </div>
- );
- }
- return null;
- })()}
- </DragOverlay>
- </DndContext>
- </div>
- )}
- </div>
- )}
- {/* Edit Modal */}
- {editItem && (
- <PrintModal
- mode="edit-queue-item"
- archiveId={editItem.archive_id ?? undefined}
- libraryFileId={editItem.library_file_id ?? undefined}
- archiveName={queueItemDisplayName(editItem, (n) => t('common.plusNMore', { count: n }))}
- queueItem={editItem}
- onClose={() => setEditItem(null)}
- />
- )}
- {/* Re-queue Modal */}
- {requeueItem && (
- <PrintModal
- mode="create"
- archiveId={requeueItem.archive_id ?? undefined}
- libraryFileId={requeueItem.library_file_id ?? undefined}
- archiveName={queueItemDisplayName(requeueItem, (n) => t('common.plusNMore', { count: n }))}
- onClose={() => setRequeueItem(null)}
- />
- )}
- {/* Confirm Action Modal */}
- {filamentShortConfirm && (
- <ConfirmModal
- title={t('queue.filamentShort.confirmTitle')}
- message={
- t('queue.filamentShort.confirmIntro') + '\n\n' +
- filamentShortConfirm.deficit
- .map((d) =>
- t('queue.filamentShort.lineItem', {
- slot: d.slot_id,
- required: Math.round(d.required_grams),
- remaining:
- d.remaining_grams == null
- ? t('queue.filamentShort.unknown')
- : Math.round(d.remaining_grams),
- }),
- )
- .join('\n')
- }
- confirmText={t('queue.filamentShort.printAnyway')}
- variant="warning"
- onConfirm={() => {
- startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true });
- }}
- onCancel={() => setFilamentShortConfirm(null)}
- />
- )}
- {confirmAction && (
- <ConfirmModal
- title={
- confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
- confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
- t('queue.confirm.removeTitle')
- }
- message={
- confirmAction.type === 'cancel'
- ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
- : confirmAction.type === 'stop'
- ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
- : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
- }
- confirmText={
- confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
- confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
- t('common.remove')
- }
- variant="danger"
- onConfirm={() => {
- if (confirmAction.type === 'cancel') {
- cancelMutation.mutate(confirmAction.item.id);
- } else if (confirmAction.type === 'stop') {
- stopMutation.mutate(confirmAction.item.id);
- } else {
- removeMutation.mutate(confirmAction.item.id);
- }
- setConfirmAction(null);
- }}
- onCancel={() => setConfirmAction(null)}
- />
- )}
- {/* #1818: Resume-after-failure confirm */}
- {resumeConfirm && (
- <ConfirmModal
- title={t('queue.resumeAfterFailure.confirmTitle')}
- message={t('queue.resumeAfterFailure.confirmMessage', {
- printer: resumeConfirm.printerName,
- count: resumeConfirm.skippedCount,
- })}
- confirmText={t('queue.resumeAfterFailure.button')}
- variant="warning"
- onConfirm={() => resumeAfterFailureMutation.mutate(resumeConfirm.printerId)}
- onCancel={() => setResumeConfirm(null)}
- />
- )}
- {/* Clear History Confirm Modal */}
- {showClearHistoryConfirm && (
- <ConfirmModal
- title={t('queue.confirm.clearHistoryTitle')}
- message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
- confirmText={t('queue.clearHistory')}
- variant="danger"
- onConfirm={() => {
- clearHistoryMutation.mutate();
- setShowClearHistoryConfirm(false);
- }}
- onCancel={() => setShowClearHistoryConfirm(false)}
- />
- )}
- {/* Bulk Edit Modal */}
- {showBulkEditModal && (
- <BulkEditModal
- selectedCount={selectedItems.length}
- printers={printers?.map(p => ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []}
- onSave={(data) => {
- if (Object.keys(data).length > 0) {
- bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
- }
- }}
- onClose={() => setShowBulkEditModal(false)}
- isSaving={bulkUpdateMutation.isPending}
- canControlPrinter={hasPermission('printers:control')}
- t={t}
- />
- )}
- {/* Group as batch modal — name prompt */}
- {groupBatchModal && (
- <GroupBatchModal
- itemCount={selectedItems.length}
- isSaving={createBatchMutation.isPending}
- defaultName={(() => {
- // Suggest a name derived from the first selected item's source.
- const first = pendingItems.find((i) => selectedItems.includes(i.id));
- const raw = first?.archive_name || first?.library_file_name || '';
- const cleaned = raw.replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
- return cleaned ? `${cleaned}` : t('queue.batch.defaultName');
- })()}
- onSave={(name) => createBatchMutation.mutate({ name, item_ids: selectedItems })}
- onClose={() => setGroupBatchModal(false)}
- t={t}
- />
- )}
- {/* Ungroup batch confirm */}
- {ungroupBatchId !== null && (
- <ConfirmModal
- title={t('queue.batch.ungroupConfirmTitle')}
- message={t('queue.batch.ungroupConfirmMessage')}
- confirmText={t('queue.batch.ungroup')}
- variant="warning"
- onConfirm={() => ungroupBatchMutation.mutate(ungroupBatchId)}
- onCancel={() => setUngroupBatchId(null)}
- />
- )}
- </div>
- );
- }
- interface GroupBatchModalProps {
- itemCount: number;
- defaultName: string;
- isSaving: boolean;
- onSave: (name: string) => void;
- onClose: () => void;
- t: (key: string, options?: Record<string, unknown>) => string;
- }
- function GroupBatchModal({ itemCount, defaultName, isSaving, onSave, onClose, t }: GroupBatchModalProps) {
- const [name, setName] = useState(defaultName);
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
- <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 w-full max-w-md">
- <h3 className="text-lg font-semibold text-white mb-2 flex items-center gap-2">
- <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
- {t('queue.batch.groupAsBatch')}
- </h3>
- <p className="text-sm text-bambu-gray mb-4">
- {t('queue.batch.groupAsBatchDescription', { count: itemCount })}
- </p>
- <label className="block text-sm font-medium text-white mb-2">
- {t('queue.batch.nameLabel')}
- </label>
- <input
- type="text"
- autoFocus
- value={name}
- onChange={(e) => setName(e.target.value)}
- placeholder={t('queue.batch.namePlaceholder')}
- maxLength={120}
- 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"
- />
- <div className="flex justify-end gap-2">
- <Button variant="ghost" size="sm" onClick={onClose} disabled={isSaving}>
- {t('common.cancel')}
- </Button>
- <Button
- variant="primary"
- size="sm"
- onClick={() => {
- const trimmed = name.trim();
- if (trimmed) onSave(trimmed);
- }}
- disabled={isSaving || !name.trim()}
- >
- {isSaving ? t('common.saving') : t('queue.batch.create')}
- </Button>
- </div>
- </div>
- </div>
- );
- }
|