diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7501392..12586326f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. +- **Drag-reorder for grouped queue items + collapsed-batch no longer blocks adjacent rows** — Print queue batch grouping (multi-plate auto-batch + manual "Group as batch") shipped with an intentional v1 limitation: the batch parent row had no drag handle and wasn't registered with the SortableContext, so a batch was stuck at whatever position it was created at. Worse, a collapsed batch acted as an unmovable obstacle that adjacent standalone items couldn't drag cleanly past. The only workaround was to ungroup, reorder, and re-group. **Now.** Batch parents register in the SortableContext under a synthetic `batch-` string id, and the parent header carries a real `GripVertical` drag handle next to its existing checkbox + collapse chevron (gated by `queue:reorder` like the per-item handle). `handleDragEnd` learned to resolve both batch ids: when the dragged source is a batch, the moving block is all of its child items in their current order; when the drop target is a batch, the anchor is the batch's first child so the group lands immediately before it. Direction-aware insertion (dragging downward inserts after the target) uses the first moving id's index instead of the previously hard-coded single dragged id, which keeps multi-row and batch drags converging on the same insert math. Within-batch reorder of expanded children is unchanged — they're still individually `useSortable`-registered and the per-row drag handle still works. The `DragOverlay` gets a second branch for batch drags showing ` ( copies)` with a Package icon, so the user sees what they're moving. **i18n.** 2 new keys (`queue.batch.dragGroup` button title + `queue.dragGhost.batch` / `..._plural` overlay text), real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5235 leaves per locale, no English fallback. The FR plural is a legitimate cognate (French plural of "copie" is also "copies") so it's pinned in `FR_COGNATES` rather than fake-translated. **Build + tests.** Full QueuePage vitest 26/26 green; `npm run build` clean; ESLint clean; i18n parity green. + - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** — The single global `ams_humidity_fair` knob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (`drying_presets` JSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. **New setting** `ams_humidity_thresholds` — JSON map of filament type to threshold percent, with an explicit `"default"` key for unknown / unmapped types. Empty / unset → both consumers fall back to the existing `ams_humidity_fair` value so the upgrade is silent (no behaviour change until the user opens the editor). **Mixed-AMS resolution: most-restrictive wins**, matching the conservative-params strategy `_get_conservative_drying_params` already uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to the `default` key. **Two consumer sites rewired in lockstep** via a single new `PrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)` static method: (1) `print_scheduler.py::_check_auto_drying` — the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2) `main.py` AMS sensor / alarm worker — the hourly notifier that fires `on_ams_humidity_high` / `on_ams_ht_humidity_high`. Both sites read the same `ams_humidity_thresholds` setting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." **UI.** New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's current `ams_humidity_fair` so the editor starts in a sensible state). Numeric inputs use a **draft-on-edit / commit-on-blur** pattern (transient `humidityDrafts: Record` state per row) so intermediate strings like `""`, `"3"`, `"7"` aren't eaten by the `[5, 95]` clamp while the user is mid-typing. The clamp fires once on `onBlur` (and `Enter` blurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (or `ams_humidity_fair` for the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing `30` into a field showing `60` snapped to `5` between keystrokes because `parseInt("3") | clamp([5, 95])` resolved to `5` before the user finished typing the second digit. **i18n.** Four new keys in the `settings.*` namespace (`humidityThresholds`, `humidityThresholdsDescription`, `humidityThresholdCol`, `humidityThresholdDefault`) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. **Tests.** 12 new backend cases in `test_scheduler_auto_drying.py::TestResolveHumidityThreshold` + `TestGetHumidityThresholds` — empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses the `default` key (not the caller fallback), empty tray slots skipped, all-empty trays use `default`, "PLA Basic" / "pla basic" both normalize to PLA, missing `tray_type` field is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literal `default` key). One updated integration test in `test_settings_ui_preferences.py` pins the new field in the public `_UI_PREFERENCE_FIELDS` allowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case in `SettingsPage.test.tsx` exercises the editor render on the Workflow tab. **Setting is in the public `/ui-preferences` allowlist** because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without granting `SETTINGS_READ` (same rationale as `drying_presets` and `ams_humidity_fair`). **What this does NOT do.** Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills from `ams_humidity_fair` and lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backend `pytest -n 30` 6270/6270 in 73 s; ruff clean; `npm run build` clean; ESLint clean; i18n parity green. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index a3d8b6f7f..5d2d78e0d 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/frontend/scripts/check-i18n-parity.mjs @@ -194,6 +194,7 @@ const FR_COGNATES = [ '{{count}} filament', '{{count}} filaments', '{{count}} permissions', '{{count}} downloads', '{{count}} item', '{{count}} selected', '({{count}} item)', 'Provisioning...', 'Pressure Advance', + '{{name}} ({{count}} copies)', // FR plural of "copie" is also "copies" 'Box label (62 × 29 mm)', 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)', 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)', diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index e7bf90303..c93937de6 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Gruppierung aufheben', ungroupConfirmTitle: 'Stapel auflösen?', ungroupConfirmMessage: 'Die Einträge bleiben in der Warteschlange, sind aber nicht mehr gruppiert.', + dragGroup: 'Gruppe ziehen', }, tabs: { queue: 'Warteschlange', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} Einträge', + batch: '{{name}} ({{count}} Kopie)', + batch_plural: '{{name}} ({{count}} Kopien)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c9ac46a10..eed9b873f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1067,6 +1067,7 @@ export default { ungroup: 'Ungroup', ungroupConfirmTitle: 'Ungroup batch?', ungroupConfirmMessage: 'The items will stay in the queue but no longer be grouped together.', + dragGroup: 'Drag group', }, // Tabs tabs: { @@ -1090,6 +1091,8 @@ export default { // Drag ghost label when multi-dragging dragGhost: { multiCount: '{{count}} items', + batch: '{{name}} ({{count}} copy)', + batch_plural: '{{name}} ({{count}} copies)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index ea44c9177..ebd7f0b35 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Desagrupar', ungroupConfirmTitle: '¿Desagrupar lote?', ungroupConfirmMessage: 'Los elementos permanecerán en la cola pero ya no estarán agrupados.', + dragGroup: 'Arrastrar grupo', }, tabs: { queue: 'Cola', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} elementos', + batch: '{{name}} ({{count}} copia)', + batch_plural: '{{name}} ({{count}} copias)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index eb2514d1e..b188926d9 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Dégrouper', ungroupConfirmTitle: 'Dégrouper le lot ?', ungroupConfirmMessage: 'Les éléments resteront dans la file mais ne seront plus groupés.', + dragGroup: 'Faire glisser le groupe', }, tabs: { queue: 'File', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} éléments', + batch: '{{name}} ({{count}} copie)', + batch_plural: '{{name}} ({{count}} copies)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 22fe48ad5..08de961cf 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Separa', ungroupConfirmTitle: 'Separare il lotto?', ungroupConfirmMessage: 'Gli elementi resteranno in coda ma non saranno più raggruppati.', + dragGroup: 'Trascina gruppo', }, tabs: { queue: 'Coda', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} elementi', + batch: '{{name}} ({{count}} copia)', + batch_plural: '{{name}} ({{count}} copie)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 3241bf1b6..a3837a5df 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1062,6 +1062,7 @@ export default { ungroup: 'グループ解除', ungroupConfirmTitle: 'バッチのグループを解除しますか?', ungroupConfirmMessage: 'アイテムはキューに残りますが、グループ化されなくなります。', + dragGroup: 'グループをドラッグ', }, tabs: { queue: 'キュー', @@ -1079,6 +1080,8 @@ export default { }, dragGhost: { multiCount: '{{count}}件', + batch: '{{name}}({{count}}部)', + batch_plural: '{{name}}({{count}}部)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index b0330e1c1..ca9d717d3 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1007,6 +1007,7 @@ export default { ungroup: '그룹 해제', ungroupConfirmTitle: '배치 그룹을 해제하시겠습니까?', ungroupConfirmMessage: '항목은 큐에 남아 있지만 더 이상 함께 그룹화되지 않습니다.', + dragGroup: '그룹 드래그', }, tabs: { queue: '큐', @@ -1024,6 +1025,8 @@ export default { }, dragGhost: { multiCount: '{{count}}개 항목', + batch: '{{name}} ({{count}}부)', + batch_plural: '{{name}} ({{count}}부)', }, sections: { currentlyPrinting: '현재 인쇄 중', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 1d7158b47..f384b57f8 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Desagrupar', ungroupConfirmTitle: 'Desagrupar lote?', ungroupConfirmMessage: 'Os itens permanecerão na fila mas não estarão mais agrupados.', + dragGroup: 'Arrastar grupo', }, tabs: { queue: 'Fila', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} itens', + batch: '{{name}} ({{count}} cópia)', + batch_plural: '{{name}} ({{count}} cópias)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 30a34e45b..1ffc012da 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -1063,6 +1063,7 @@ export default { ungroup: 'Gruptan çıkar', ungroupConfirmTitle: 'Yığını gruptan çıkar?', ungroupConfirmMessage: 'Öğeler kuyrukta kalacak ancak artık birlikte gruplanmayacak.', + dragGroup: 'Grubu sürükle', }, tabs: { queue: 'Kuyruk', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} öğe', + batch: '{{name}} ({{count}} kopya)', + batch_plural: '{{name}} ({{count}} kopya)', }, // Bölümler sections: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 7554eac6c..b89c70ff5 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1063,6 +1063,7 @@ export default { ungroup: '取消分组', ungroupConfirmTitle: '取消批次分组?', ungroupConfirmMessage: '项目将保留在队列中,但不再分组在一起。', + dragGroup: '拖动批次', }, tabs: { queue: '队列', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} 项', + batch: '{{name}}({{count}} 份)', + batch_plural: '{{name}}({{count}} 份)', }, // Sections sections: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index f5996afc1..d8276e164 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -1063,6 +1063,7 @@ export default { ungroup: '取消分組', ungroupConfirmTitle: '取消批次分組?', ungroupConfirmMessage: '項目將保留在佇列中,但不再分組在一起。', + dragGroup: '拖曳批次', }, tabs: { queue: '佇列', @@ -1080,6 +1081,8 @@ export default { }, dragGhost: { multiCount: '{{count}} 項', + batch: '{{name}}({{count}} 份)', + batch_plural: '{{name}}({{count}} 份)', }, // Sections sections: { diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 931d0e83c..4dbe9f5d0 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -771,22 +771,20 @@ interface QueueRowRenderProps { /** 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({ - row, - collapsed, - onToggleBatch, - onUngroup, - setEditItem, - setConfirmAction, - startMutation, - selectedItems, - handleToggleSelect, - timeFormat, - hasPermission, - canModify, - t, - aggregateForRows, -}: QueueRowRenderProps) { +function QueueRowRender(props: QueueRowRenderProps) { + const { + row, + setEditItem, + setConfirmAction, + startMutation, + selectedItems, + handleToggleSelect, + timeFormat, + hasPermission, + canModify, + t, + } = props; + if (row.kind === 'item') { return ( i.id); + return ; +} + +/** 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, + aggregateForRows, +}: 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; + 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(row.items.map((i) => i.status)); + 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 = row.items.filter((i) => i.status === 'pending').length; + const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length; return ( -
+
{/* Parent header */}
+ {canReorder && ( +
+ +
+ )}