فهرست منبع

feat(inventory): batch / mass edit on the Filament tab (#1795)

  Bulk operations on the Inventory page in both built-in and Spoolman modes.
  Reporter wanted ten-of-the-same-spool edits without ten round-trips through
  the per-spool editor.

  Frontend
  - New checkbox column on the inventory table (header / row / group). Sticky
    toolbar appears when at least one row is selected with Edit / Print labels /
    Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
    Selection clears on any filter / tab / search change so the count can't
    drift from what is on screen.
  - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
    field by ticking its checkbox or just typing into it; only ticked + non-
    empty fields are sent. Clearing fields in bulk is intentionally NOT
    supported per the issue discussion.
  - A new SearchableSelect renders all categorical fields (material, sub-type,
    brand, category, slicer preset name, slicer filament, storage location)
    with the same dropdown pattern the per-spool editor uses - text input +
    chevron + filtered button list, click-outside / Escape closes. No native
    select anywhere in the modal. Options merge the canonical constants from
    spool-form/constants.ts with whatever already exists in the user's
    inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
    form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
    Options() and three useQuery calls gated on isOpen.
  - onSuccess handlers surface three outcomes: all-succeeded (green toast),
    partial-success (yellow toast with ok / failed counts), all-failed (red
    toast that keeps the selection and modal open so the user can retry).
    The first cut silently dropped errors / not_found arrays - audited and
    fixed before merge.
  - Invalid rgba hex is flagged inline with a red border + helper text and
    the Apply button is gated on a hasDroppedTickedField guard, so silently
    dropping a ticked field is no longer possible.
  - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
    clears selection, matching the other three bulk mutations.

  Backend
  - Four new endpoints per inventory mode (eight total):
      POST /api/v1/inventory/spools/bulk-update         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-delete         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-archive        INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-restore        INVENTORY_UPDATE
      POST /api/v1/spoolman/inventory/spools/bulk-*     FILAMENTS_UPDATE
  - Built-in update runs the same prepare_internal_spool_payload(...) +
    weight_used / weight_locked auto-stamp as the per-spool PATCH.
  - Spoolman update loops the per-spool update_spool route function so the
    filament re-linking / extra-dict / extra-lock / shared-filament rules
    stay byte-identical to single-spool edits.
  - Per-spool failures inside the batch are collected. Spoolman bulk-delete /
    archive / restore now catch non-HTTPException too (matches bulk-update) -
    a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
    with a 500 and skips the WS broadcast.
  - Both modes broadcast a single inventory_changed WS event at the end of
    the batch.
maziggy 2 ماه پیش
والد
کامیت
fb3821630f

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 120 - 0
backend/app/api/routes/inventory.py

@@ -1431,6 +1431,126 @@ async def bulk_reset_spool_consumed_counter(
     return {"reset": len(spools)}
 
 
+class BulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolUpdate
+
+
+class BulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: BulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Apply the same partial update to every listed spool.
+
+    Per-spool errors are collected and returned alongside the success count so
+    a single bad ID doesn't abort the whole batch. Unknown IDs are reported
+    in the ``not_found`` list.
+    """
+    update_data = payload.update.model_dump(exclude_unset=True)
+    fields_set = set(payload.update.model_fields_set)
+    if not update_data:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+    try:
+        prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    # Auto-lock weight when the user explicitly sets weight_used — mirrors the
+    # per-spool PATCH behaviour so bulk edits don't desync the lock state.
+    if "weight_used" in prepared and "weight_locked" not in prepared:
+        prepared["weight_locked"] = True
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = {s.id: s for s in result.scalars().all()}
+    not_found = [sid for sid in payload.ids if sid not in spools]
+    updated_ids: list[int] = []
+    for sid, spool in spools.items():
+        for field, value in prepared.items():
+            setattr(spool, field, value)
+        updated_ids.append(sid)
+    await db.commit()
+    if updated_ids:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": len(updated_ids), "not_found": not_found}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    for spool in spools:
+        await db.delete(spool)
+    await db.commit()
+    if spools:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": len(spools), "not_found": not_found}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
+    from datetime import datetime, timezone
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    archived: list[int] = []
+    already: list[int] = []
+    now = datetime.now(timezone.utc)
+    for spool in spools:
+        if spool.archived_at is not None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = now
+        archived.append(spool.id)
+    await db.commit()
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": len(archived), "already_archived": already, "not_found": not_found}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    restored: list[int] = []
+    already: list[int] = []
+    for spool in spools:
+        if spool.archived_at is None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = None
+        restored.append(spool.id)
+    await db.commit()
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": len(restored), "already_active": already, "not_found": not_found}
+
+
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 
 

+ 117 - 0
backend/app/api/routes/spoolman_inventory.py

@@ -956,6 +956,123 @@ async def reset_spool_consumed_counter(
     return mapped
 
 
+class SpoolmanBulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolmanInventoryUpdate
+
+
+class SpoolmanBulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: SpoolmanBulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Apply the same partial update to every listed Spoolman spool.
+
+    Loops the per-spool ``update_spool`` route so the filament re-linking +
+    extra-dict + location-resolution rules stay in sync with the single-spool
+    PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
+    batch.
+    """
+    update_fields = payload.update.model_dump(exclude_unset=True)
+    if not update_fields:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+
+    updated = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
+            updated += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-update failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if updated:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": updated, "errors": errors}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    deleted = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.delete_spool(sid)
+            deleted += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-delete failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if deleted:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": deleted, "errors": errors}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Archive every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    archived = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=True)
+            archived += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-archive failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": archived, "errors": errors}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Restore every listed archived Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    restored = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=False)
+            restored += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-restore failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": restored, "errors": errors}
+
+
 @router.post("/spools/reset-consumed-counter-bulk")
 async def bulk_reset_spool_consumed_counter(
     payload: dict = Body(...),

+ 212 - 0
backend/tests/integration/test_inventory_bulk.py

@@ -0,0 +1,212 @@
+"""Bulk inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/inventory/spools/bulk-update
+- POST /api/v1/inventory/spools/bulk-delete
+- POST /api/v1/inventory/spools/bulk-archive
+- POST /api/v1/inventory/spools/bulk-restore
+
+The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+            "weight_used_baseline": 0,
+            "weight_locked": False,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+class TestBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(brand="Bambu", note=None)
+        b = await spool_factory(brand="Bambu", note=None)
+        c = await spool_factory(brand="Bambu", note=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["not_found"] == []
+
+        for spool in (a, b, c):
+            await db_session.refresh(spool)
+            assert spool.brand == "Sunlu"
+            assert spool.note == "From bulk edit"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
+        real = await spool_factory(brand="Bambu")
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 1
+        assert body["not_found"] == [999_999]
+
+        await db_session.refresh(real)
+        assert real.brand == "Sunlu"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(weight_locked=False, weight_used=0.0)
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {"weight_used": 250.5}},
+        )
+        assert resp.status_code == 200
+        await db_session.refresh(a)
+        assert a.weight_used == 250.5
+        assert a.weight_locked is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"brand": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+        kept = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 2
+        assert body["not_found"] == []
+
+        remaining = (await db_session.execute(select(Spool.id))).scalars().all()
+        assert kept.id in remaining
+        assert a.id not in remaining
+        assert b.id not in remaining
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, 999_999]},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 1
+        assert body["not_found"] == [999_999]
+
+
+class TestBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        assert body["already_archived"] == []
+        assert body["not_found"] == []
+
+        for s in (a, b):
+            await db_session.refresh(s)
+            assert s.archived_at is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
+        active = await spool_factory()
+        already = await spool_factory(archived_at=datetime.now(timezone.utc))
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [active.id, already.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 1
+        assert body["already_archived"] == [already.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        archived = await spool_factory(archived_at=datetime.now(timezone.utc))
+        active = await spool_factory(archived_at=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-restore",
+            json={"ids": [archived.id, active.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 1
+        assert body["already_active"] == [active.id]
+
+        await db_session.refresh(archived)
+        assert archived.archived_at is None

+ 200 - 0
backend/tests/integration/test_spoolman_inventory_bulk.py

@@ -0,0 +1,200 @@
+"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/spoolman/inventory/spools/bulk-update
+- POST /api/v1/spoolman/inventory/spools/bulk-delete
+- POST /api/v1/spoolman/inventory/spools/bulk-archive
+- POST /api/v1/spoolman/inventory/spools/bulk-restore
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from httpx import AsyncClient
+
+SAMPLE_SPOOLMAN_SPOOL = {
+    "id": 42,
+    "filament": {
+        "id": 7,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "color_hex": "FF0000",
+        "weight": 1000,
+        "vendor": {"id": 3, "name": "Bambu Lab"},
+    },
+    "remaining_weight": 750.0,
+    "used_weight": 250.0,
+    "location": "Printer1 - AMS A1",
+    "comment": "test note",
+    "first_used": "2024-01-01T00:00:00+00:00",
+    "last_used": "2024-02-01T00:00:00+00:00",
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    mock = MagicMock()
+    mock.base_url = "http://localhost:7912"
+    mock.health_check = AsyncMock(return_value=True)
+    mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.delete_spool = AsyncMock(return_value=True)
+    mock.set_spool_archived = AsyncMock(
+        side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
+    )
+    mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.is_filament_shared = AsyncMock(return_value=False)
+    mock.patch_filament = AsyncMock(return_value={"id": 7})
+    mock.find_or_create_filament = AsyncMock(return_value=7)
+    mock.find_or_create_vendor = AsyncMock(return_value=3)
+    mock.ensure_extra_field = AsyncMock(return_value=True)
+    mock.get_distinct_locations = AsyncMock(return_value=[])
+
+    class _Lock:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *args):
+            return False
+
+    mock.extra_lock = lambda spool_id: _Lock()
+
+    with (
+        patch(
+            "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+        patch(
+            "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+    ):
+        yield mock
+
+
+class TestSpoolmanBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calls_per_spool_update_for_each_id(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["errors"] == []
+        # update_spool route loops through each, which calls update_spool_full once per ID
+        assert mock_spoolman_client.update_spool_full.await_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_collects_per_spool_errors_without_aborting_batch(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        # First two succeed, third raises
+        mock_spoolman_client.update_spool_full.side_effect = [
+            SAMPLE_SPOOLMAN_SPOOL,
+            SAMPLE_SPOOLMAN_SPOOL,
+            HTTPException(status_code=404, detail="Spool 999 not found"),
+        ]
+
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 2
+        assert len(body["errors"]) == 1
+        assert body["errors"][0]["id"] == 999
+        assert body["errors"][0]["status"] == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"note": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestSpoolmanBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-delete",
+            json={"ids": [42, 43, 44]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 3
+        assert body["errors"] == []
+        assert mock_spoolman_client.delete_spool.await_count == 3
+
+
+class TestSpoolmanBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-archive",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        # set_spool_archived(spool_id, archived=True) called for each id
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-restore",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 2
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is False

+ 40 - 0
frontend/src/api/client.ts

@@ -5129,6 +5129,26 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
+  bulkUpdateSpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<{ updated: number; not_found: number[] }>(`/inventory/spools/bulk-update`, {
+      method: 'POST',
+      body: JSON.stringify({ ids, update }),
+    }),
+  bulkDeleteSpools: (ids: number[]) =>
+    request<{ deleted: number; not_found: number[] }>(`/inventory/spools/bulk-delete`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkArchiveSpools: (ids: number[]) =>
+    request<{ archived: number; already_archived: number[]; not_found: number[] }>(`/inventory/spools/bulk-archive`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkRestoreSpools: (ids: number[]) =>
+    request<{ restored: number; already_active: number[]; not_found: number[] }>(`/inventory/spools/bulk-restore`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
   getSpoolKProfiles: (spoolId: number) =>
     request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
   saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
@@ -5308,6 +5328,26 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
+  bulkUpdateSpoolmanInventorySpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<{ updated: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-update`, {
+      method: 'POST',
+      body: JSON.stringify({ ids, update }),
+    }),
+  bulkDeleteSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ deleted: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-delete`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkArchiveSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ archived: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-archive`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkRestoreSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ restored: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-restore`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
   linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
       method: 'PATCH',

+ 525 - 0
frontend/src/components/BulkEditSpoolsModal.tsx

@@ -0,0 +1,525 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { X, Loader2, ChevronDown } from 'lucide-react';
+import { api } from '../api/client';
+import type { InventorySpool } from '../api/client';
+import { Button } from './Button';
+import { MATERIALS, DEFAULT_BRANDS, KNOWN_VARIANTS } from './spool-form/constants';
+import { buildFilamentOptions } from './spool-form/utils';
+
+/** Subset of InventorySpool fields the bulk-edit modal can patch.
+ *  Mirrors the agreed set discussed for #1795 — flat per-spool fields only;
+ *  K-profile editing stays per-spool.
+ */
+type EditableField =
+  | 'material'
+  | 'subtype'
+  | 'brand'
+  | 'color_name'
+  | 'rgba'
+  | 'location_id'
+  | 'slicer_filament_name'
+  | 'slicer_filament'
+  | 'cost_per_kg'
+  | 'note'
+  | 'label_weight'
+  | 'core_weight'
+  | 'category'
+  | 'low_stock_threshold_pct';
+
+type FieldSpec = {
+  id: EditableField;
+  /** searchable = custom dropdown with text input + filtered options (free text allowed).
+   *  searchableClosed = same but no custom value (must pick from list — used for storage_location).
+   *  text = plain text input.
+   *  number = number input.
+   *  color = colour picker + hex input.
+   *  textarea = multi-line text. */
+  type: 'searchable' | 'searchableClosed' | 'text' | 'number' | 'color' | 'textarea';
+  labelKey: string;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Hex pattern for the rgba field. */
+  pattern?: string;
+};
+
+const FIELDS: FieldSpec[] = [
+  { id: 'material', type: 'searchable', labelKey: 'inventory.material' },
+  { id: 'subtype', type: 'searchable', labelKey: 'inventory.subtype' },
+  { id: 'brand', type: 'searchable', labelKey: 'inventory.brand' },
+  { id: 'color_name', type: 'text', labelKey: 'inventory.colorName' },
+  { id: 'rgba', type: 'color', labelKey: 'inventory.color', pattern: '^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$' },
+  { id: 'location_id', type: 'searchableClosed', labelKey: 'inventory.storageLocation' },
+  { id: 'slicer_filament_name', type: 'searchable', labelKey: 'inventory.slicerFilamentName' },
+  { id: 'slicer_filament', type: 'searchable', labelKey: 'inventory.slicerFilament' },
+  { id: 'cost_per_kg', type: 'number', labelKey: 'inventory.costPerKg', min: 0, step: 0.01 },
+  { id: 'note', type: 'textarea', labelKey: 'inventory.note' },
+  { id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
+  { id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
+  { id: 'category', type: 'searchable', labelKey: 'inventory.category' },
+  { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
+];
+
+export interface BulkEditSpoolsModalProps {
+  isOpen: boolean;
+  selectedCount: number;
+  isPending: boolean;
+  availableLocations: Array<{ id: number; name: string }>;
+  /** Materials seen in the user's inventory — combined with the MATERIALS constant for suggestions. */
+  availableMaterials: string[];
+  availableSubtypes: string[];
+  availableBrands: string[];
+  availableCategories: string[];
+  availableSlicerFilaments: string[];
+  availableSlicerFilamentNames: string[];
+  onClose: () => void;
+  onApply: (patch: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => void;
+}
+
+interface Option {
+  value: string;
+  label: string;
+}
+
+interface SearchableSelectProps {
+  value: string;
+  onChange: (next: string) => void;
+  options: Option[];
+  /** When true the user can also type a value not present in the option list. */
+  allowCustom: boolean;
+  placeholderKey?: string;
+  disabled?: boolean;
+}
+
+/** Lightweight searchable dropdown matching the per-spool form's pattern —
+ *  text input + chevron + filtered list of buttons, click-outside closes.
+ *  Native `<select>` is intentionally avoided per the project's UI conventions. */
+function SearchableSelect({ value, onChange, options, allowCustom, placeholderKey, disabled }: SearchableSelectProps) {
+  const { t } = useTranslation();
+  const ref = useRef<HTMLDivElement>(null);
+  const [open, setOpen] = useState(false);
+  const [search, setSearch] = useState('');
+
+  useEffect(() => {
+    if (!open) return;
+    const onDocClick = (e: MouseEvent) => {
+      if (ref.current && !ref.current.contains(e.target as Node)) {
+        setOpen(false);
+        setSearch('');
+      }
+    };
+    const onEsc = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        setOpen(false);
+        setSearch('');
+      }
+    };
+    document.addEventListener('mousedown', onDocClick);
+    document.addEventListener('keydown', onEsc);
+    return () => {
+      document.removeEventListener('mousedown', onDocClick);
+      document.removeEventListener('keydown', onEsc);
+    };
+  }, [open]);
+
+  const displayValue = (() => {
+    if (open) return search;
+    const match = options.find((o) => o.value === value);
+    return match?.label ?? value;
+  })();
+
+  const filteredOptions = useMemo(() => {
+    if (!open) return options;
+    const q = search.trim().toLowerCase();
+    if (!q) return options;
+    return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
+  }, [open, search, options]);
+
+  const noOptionMatch = open && search.trim() && !options.some((o) => o.value.toLowerCase() === search.trim().toLowerCase());
+
+  return (
+    <div className="relative" ref={ref}>
+      <input
+        type="text"
+        disabled={disabled}
+        value={displayValue}
+        onChange={(e) => {
+          setSearch(e.target.value);
+          setOpen(true);
+          if (allowCustom) onChange(e.target.value);
+        }}
+        onFocus={() => {
+          setOpen(true);
+          setSearch('');
+        }}
+        placeholder={placeholderKey ? t(placeholderKey) : undefined}
+        className="w-full px-3 py-2 pr-9 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
+      />
+      <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+      {open && (
+        <div className="absolute z-50 left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
+          {filteredOptions.length === 0 && !allowCustom && (
+            <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+          )}
+          {filteredOptions.map((opt) => (
+            <button
+              key={opt.value}
+              type="button"
+              className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                value === opt.value ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+              }`}
+              onClick={() => {
+                onChange(opt.value);
+                setOpen(false);
+                setSearch('');
+              }}
+            >
+              {opt.label}
+            </button>
+          ))}
+          {allowCustom && noOptionMatch && (
+            <button
+              type="button"
+              className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
+              onClick={() => {
+                onChange(search.trim());
+                setOpen(false);
+                setSearch('');
+              }}
+            >
+              {t('inventory.bulk.useCustom', { value: search.trim() })}
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function combineUnique(...lists: string[][]): string[] {
+  const set = new Set<string>();
+  for (const list of lists) for (const v of list) {
+    const trimmed = v?.trim();
+    if (trimmed) set.add(trimmed);
+  }
+  return Array.from(set).sort((a, b) => a.localeCompare(b));
+}
+
+export function BulkEditSpoolsModal({
+  isOpen, selectedCount, isPending,
+  availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
+  availableSlicerFilaments, availableSlicerFilamentNames,
+  onClose, onApply,
+}: BulkEditSpoolsModalProps) {
+  const { t } = useTranslation();
+
+  // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
+  // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
+  const { data: cloudPresets = [] } = useQuery({
+    queryKey: ['bulk-edit-cloud-presets'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: async () => {
+      const out: Awaited<ReturnType<typeof api.getFilamentPresets>> = [];
+      try {
+        const status = await api.getCloudStatus();
+        if (status.is_authenticated) {
+          const bambu = await api.getFilamentPresets();
+          out.push(...bambu);
+        }
+      } catch {/* cloud offline → empty */}
+      try {
+        const orca = await api.orcaCloudStatus();
+        if (orca.connected) {
+          const list = await api.orcaCloudListProfiles();
+          out.push(...(list.filament as unknown as typeof out));
+        }
+      } catch {/* orca offline → empty */}
+      return out;
+    },
+  });
+  const { data: localPresetsResp } = useQuery({
+    queryKey: ['bulk-edit-local-presets'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: api.getLocalPresets,
+  });
+  const { data: builtinFilaments = [] } = useQuery({
+    queryKey: ['builtin-filaments'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: api.getBuiltinFilaments,
+  });
+  const filamentOptions = useMemo(
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresetsResp?.filament ?? [], builtinFilaments),
+    [cloudPresets, localPresetsResp, builtinFilaments],
+  );
+  // Per-field state: each entry is either undefined (leave unchanged) or
+  // the new value. Clearing fields in bulk is intentionally NOT supported
+  // (user decision on #1795): leave clearing to the per-spool editor so
+  // an accidental "blank everything" isn't a single mis-click away.
+  const [values, setValues] = useState<Record<string, string>>({});
+
+  // Merge inventory-seen values with the canonical option lists so users
+  // see the same dropdown choices the per-spool editor surfaces.
+  const materialOptions: Option[] = useMemo(
+    () => combineUnique(MATERIALS, availableMaterials).map((m) => ({ value: m, label: m })),
+    [availableMaterials],
+  );
+  const subtypeOptions: Option[] = useMemo(
+    () => combineUnique(KNOWN_VARIANTS, availableSubtypes).map((m) => ({ value: m, label: m })),
+    [availableSubtypes],
+  );
+  const brandOptions: Option[] = useMemo(
+    () => combineUnique(DEFAULT_BRANDS, availableBrands).map((m) => ({ value: m, label: m })),
+    [availableBrands],
+  );
+  const categoryOptions: Option[] = useMemo(
+    () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
+    [availableCategories],
+  );
+  const slicerFilamentOptions: Option[] = useMemo(() => {
+    // value = preset code (what goes into spool.slicer_filament),
+    // label = display name so the user can find it by name.
+    const fromPresets = filamentOptions.map((p) => ({ value: p.code, label: p.displayName }));
+    const fromInventory = availableSlicerFilaments
+      .filter((code) => !fromPresets.some((p) => p.value === code))
+      .map((code) => ({ value: code, label: code }));
+    return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
+  }, [filamentOptions, availableSlicerFilaments]);
+  const slicerFilamentNameOptions: Option[] = useMemo(() => {
+    const fromPresets = filamentOptions.map((p) => ({ value: p.displayName, label: p.displayName }));
+    const fromInventory = availableSlicerFilamentNames
+      .filter((name) => !fromPresets.some((p) => p.value === name))
+      .map((name) => ({ value: name, label: name }));
+    return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
+  }, [filamentOptions, availableSlicerFilamentNames]);
+  const locationOptions: Option[] = useMemo(
+    () => availableLocations.map((l) => ({ value: String(l.id), label: l.name })),
+    [availableLocations],
+  );
+
+  if (!isOpen) return null;
+
+  const setField = (id: EditableField, value: string) => {
+    setValues((prev) => ({ ...prev, [id]: value }));
+  };
+
+  const unsetField = (id: EditableField) => {
+    setValues((prev) => {
+      const next = { ...prev };
+      delete next[id];
+      return next;
+    });
+  };
+
+  const buildPatch = (): Record<string, string | number> => {
+    const patch: Record<string, string | number> = {};
+    for (const f of FIELDS) {
+      const raw = values[f.id];
+      if (raw === undefined) continue;
+      const trimmed = typeof raw === 'string' ? raw.trim() : raw;
+      if (trimmed === '' || trimmed === null) continue;
+      if (f.type === 'number') {
+        const n = Number(trimmed);
+        if (Number.isFinite(n)) patch[f.id] = n;
+      } else if (f.id === 'location_id') {
+        const n = Number(trimmed);
+        if (Number.isFinite(n) && n > 0) patch[f.id] = n;
+      } else if (f.id === 'rgba') {
+        const hex = String(trimmed).replace(/^#/, '');
+        const normalized = hex.length === 6 ? `${hex}FF` : hex;
+        if (/^[0-9A-Fa-f]{8}$/.test(normalized)) patch[f.id] = normalized.toUpperCase();
+      } else {
+        patch[f.id] = String(trimmed);
+      }
+    }
+    return patch;
+  };
+
+  const patch = buildPatch();
+  const hasChanges = Object.keys(patch).length > 0;
+  // Block Apply when any ticked-and-non-empty field has invalid input that
+  // would be silently dropped from the patch — e.g. a malformed rgba hex.
+  // Without this guard the user clicks Apply, the field is dropped, and the
+  // success toast still fires for the OTHER fields.
+  const hasDroppedTickedField = FIELDS.some((f) => {
+    const raw = values[f.id];
+    if (raw === undefined) return false;
+    if (raw.trim() === '') return false;
+    return patch[f.id] === undefined;
+  });
+
+  const optionsFor = (id: EditableField): Option[] => {
+    if (id === 'material') return materialOptions;
+    if (id === 'subtype') return subtypeOptions;
+    if (id === 'brand') return brandOptions;
+    if (id === 'category') return categoryOptions;
+    if (id === 'slicer_filament') return slicerFilamentOptions;
+    if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
+    if (id === 'location_id') return locationOptions;
+    return [];
+  };
+
+  const renderInput = (f: FieldSpec) => {
+    const value = values[f.id] ?? '';
+
+    if (f.type === 'searchable' || f.type === 'searchableClosed') {
+      return (
+        <SearchableSelect
+          value={value}
+          onChange={(next) => {
+            if (next === '') unsetField(f.id);
+            else setField(f.id, next);
+          }}
+          options={optionsFor(f.id)}
+          allowCustom={f.type === 'searchable'}
+          disabled={isPending}
+        />
+      );
+    }
+
+    if (f.type === 'textarea') {
+      return (
+        <textarea
+          disabled={isPending}
+          value={value}
+          onChange={(e) => setField(f.id, e.target.value)}
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none resize-none min-h-[60px]"
+        />
+      );
+    }
+
+    if (f.type === 'color') {
+      const hexCandidate = value.trim().replace(/^#/, '');
+      const normalized = hexCandidate.length === 6 ? `${hexCandidate}FF` : hexCandidate;
+      const isInvalid = value.trim() !== '' && !/^[0-9A-Fa-f]{8}$/.test(normalized);
+      return (
+        <div>
+          <div className="flex items-center gap-2">
+            <input
+              type="color"
+              disabled={isPending}
+              value={`#${(value || '808080').replace(/^#/, '').slice(0, 6)}`}
+              onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
+              className="h-9 w-12 rounded cursor-pointer"
+            />
+            <input
+              type="text"
+              disabled={isPending}
+              value={value}
+              onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
+              placeholder="RRGGBB or RRGGBBAA"
+              className={`flex-1 px-3 py-2 bg-bambu-dark border rounded-lg text-white placeholder-bambu-gray/50 focus:outline-none ${isInvalid ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'}`}
+              pattern={f.pattern}
+            />
+          </div>
+          {isInvalid && (
+            <p className="mt-1 text-xs text-red-400">{t('inventory.bulk.invalidHex')}</p>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <input
+        type={f.type === 'number' ? 'number' : 'text'}
+        disabled={isPending}
+        value={value}
+        onChange={(e) => setField(f.id, e.target.value)}
+        min={f.min}
+        max={f.max}
+        step={f.step}
+        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
+      />
+    );
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
+      onClick={isPending ? undefined : onClose}
+    >
+      <div
+        className="w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between p-5 border-b border-bambu-dark-tertiary">
+          <div>
+            <h2 className="text-lg font-semibold text-white">
+              {t('inventory.bulk.editTitle')}
+            </h2>
+            <p className="text-sm text-bambu-gray mt-0.5">
+              {t('inventory.bulk.editSubtitle', { count: selectedCount })}
+            </p>
+          </div>
+          <button
+            onClick={onClose}
+            disabled={isPending}
+            className="p-1 text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <p className="px-5 pt-4 text-xs text-bambu-gray">
+          {t('inventory.bulk.editHint')}
+        </p>
+        <div className="flex-1 overflow-y-auto p-5 space-y-3">
+          {FIELDS.map((f) => {
+            const enabled = values[f.id] !== undefined;
+            return (
+              <div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>
+                <div className="pt-2">
+                  <input
+                    type="checkbox"
+                    className="h-4 w-4 cursor-pointer"
+                    checked={enabled}
+                    onChange={(e) => {
+                      if (e.target.checked) setField(f.id, '');
+                      else unsetField(f.id);
+                    }}
+                    aria-label={t('inventory.bulk.toggleField')}
+                  />
+                </div>
+                <div className="flex-1">
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t(f.labelKey)}
+                  </label>
+                  {renderInput(f)}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+
+        <div className="flex items-center gap-3 p-5 border-t border-bambu-dark-tertiary">
+          <span className="text-xs text-bambu-gray">
+            {t('inventory.bulk.changeCount', { count: Object.keys(patch).length })}
+          </span>
+          <div className="ml-auto flex gap-2">
+            <Button variant="secondary" onClick={onClose} disabled={isPending}>
+              {t('common.cancel')}
+            </Button>
+            <Button
+              onClick={() => onApply(patch as Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>)}
+              disabled={!hasChanges || isPending || hasDroppedTickedField}
+            >
+              {isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('inventory.bulk.applyPending')}
+                </>
+              ) : (
+                t('inventory.bulk.applyButton', { count: selectedCount })
+              )}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 46 - 0
frontend/src/i18n/locales/de.ts

@@ -3850,6 +3850,52 @@ export default {
     unknownSpoolTitle: 'Neues Filament erkannt',
     unknownSpoolMessage: 'An {{location}} wurde eine Spule mit unbekanntem RFID-Tag erkannt. Jetzt zum Inventar hinzufügen?',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Alle sichtbaren auswählen',
+      selectRow: 'Zeile auswählen',
+      selectGroup: 'Gruppe auswählen',
+      selectionCount: '{{count}} ausgewählt',
+      edit: 'Bearbeiten',
+      printLabels: 'Etiketten drucken',
+      resetUsage: 'Verbrauch zurücksetzen',
+      restore: 'Wiederherstellen',
+      archive: 'Archivieren',
+      delete: 'Löschen',
+      clearSelection: 'Auswahl aufheben',
+      editTitle: 'Spulen sammelbearbeiten',
+      editSubtitle: 'Wird auf {{count}} ausgewählte Spulen angewendet. Nur angekreuzte Felder werden aktualisiert.',
+      editHint: 'In ein Feld tippen markiert es für die Aktualisierung — nur angekreuzte Zeilen werden gesendet. Leere Felder bleiben unverändert (Felder leeren geht nur pro Spule).',
+      useCustom: '„{{value}}" verwenden',
+      toggleField: 'Aktualisierung für dieses Feld umschalten',
+      changeCount: '{{count}} Felder werden aktualisiert.',
+      applyPending: 'Wird angewendet...',
+      applyButton: 'Auf {{count}} Spulen anwenden',
+      deleteTitle: 'Ausgewählte Spulen löschen',
+      archiveTitle: 'Ausgewählte Spulen archivieren',
+      restoreTitle: 'Ausgewählte Spulen wiederherstellen',
+      resetUsageTitle: 'Verbrauch ausgewählter Spulen zurücksetzen',
+      deleteMessage: '{{count}} Spulen dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.',
+      archiveMessage: '{{count}} Spulen archivieren? Sie können später wiederhergestellt werden.',
+      restoreMessage: '{{count}} archivierte Spulen wiederherstellen?',
+      resetUsageMessage: 'Den "Gesamtverbrauch"-Zähler auf {{count}} Spulen zurücksetzen? Die verbleibende Menge bleibt erhalten.',
+      updateSuccess: '{{count}} Spulen aktualisiert',
+      updateFailed: 'Sammelaktualisierung fehlgeschlagen',
+      updatePartial: '{{ok}} Spulen aktualisiert, {{failed}} fehlgeschlagen',
+      updateAllFailed: 'Alle {{count}} Aktualisierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      deleteSuccess: '{{count}} Spulen gelöscht',
+      deleteFailed: 'Sammellöschung fehlgeschlagen',
+      deletePartial: '{{ok}} Spulen gelöscht, {{failed}} fehlgeschlagen',
+      deleteAllFailed: 'Alle {{count}} Löschungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      archiveSuccess: '{{count}} Spulen archiviert',
+      archiveFailed: 'Sammelarchivierung fehlgeschlagen',
+      archivePartial: '{{ok}} Spulen archiviert, {{failed}} fehlgeschlagen',
+      archiveAllFailed: 'Alle {{count}} Archivierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      restoreSuccess: '{{count}} Spulen wiederhergestellt',
+      restoreFailed: 'Sammelwiederherstellung fehlgeschlagen',
+      restorePartial: '{{ok}} Spulen wiederhergestellt, {{failed}} fehlgeschlagen',
+      restoreAllFailed: 'Alle {{count}} Wiederherstellungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      invalidHex: '6 Hex-Zeichen (RRGGBB) oder 8 (RRGGBBAA) eingeben. Anderenfalls wird das Feld nicht übernommen.',
+    },
     spoolmanMixedContentTitle: 'Spoolman lässt sich nicht über HTTPS laden — Browser blockiert gemischte Inhalte',
     spoolmanMixedContentBody: 'Bambuddy wird über HTTPS ausgeliefert (über deinen Reverse-Proxy), aber deine Spoolman-URL ist nach wie vor HTTP. Browser blockieren gemischte Inhalte aus Sicherheitsgründen, daher kann die eingebettete Spoolman-Oberfläche nicht geladen werden. Spoolman muss ebenfalls über HTTPS erreichbar sein.',
     spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',

+ 46 - 0
frontend/src/i18n/locales/en.ts

@@ -3865,6 +3865,52 @@ export default {
     unknownSpoolTitle: 'New filament detected',
     unknownSpoolMessage: 'A spool with an unknown RFID tag was detected at {{location}}. Add it to your inventory now?',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Select all visible',
+      selectRow: 'Select row',
+      selectGroup: 'Select group',
+      selectionCount: '{{count}} selected',
+      edit: 'Edit',
+      printLabels: 'Print labels',
+      resetUsage: 'Reset usage',
+      restore: 'Restore',
+      archive: 'Archive',
+      delete: 'Delete',
+      clearSelection: 'Clear selection',
+      editTitle: 'Bulk edit spools',
+      editSubtitle: 'Applies to {{count}} selected spools. Only fields you tick get updated.',
+      editHint: 'Type into a field to mark it for update — only ticked rows are sent. Leaving a field empty leaves the spools unchanged (clearing fields is per-spool only).',
+      useCustom: 'Use "{{value}}"',
+      toggleField: 'Toggle update for this field',
+      changeCount: '{{count}} fields will be updated.',
+      applyPending: 'Applying...',
+      applyButton: 'Apply to {{count}} spools',
+      deleteTitle: 'Delete selected spools',
+      archiveTitle: 'Archive selected spools',
+      restoreTitle: 'Restore selected spools',
+      resetUsageTitle: 'Reset usage on selected spools',
+      deleteMessage: 'Permanently delete {{count}} spools? This cannot be undone.',
+      archiveMessage: 'Archive {{count}} spools? They can be restored later.',
+      restoreMessage: 'Restore {{count}} archived spools?',
+      resetUsageMessage: 'Reset the "Total Consumed" counter on {{count}} spools? Remaining weight is preserved.',
+      updateSuccess: '{{count}} spools updated',
+      updateFailed: 'Bulk update failed',
+      updatePartial: '{{ok}} spools updated, {{failed}} failed',
+      updateAllFailed: 'All {{count}} spool updates failed — selection kept so you can retry',
+      deleteSuccess: '{{count}} spools deleted',
+      deleteFailed: 'Bulk delete failed',
+      deletePartial: '{{ok}} spools deleted, {{failed}} failed',
+      deleteAllFailed: 'All {{count}} spool deletions failed — selection kept so you can retry',
+      archiveSuccess: '{{count}} spools archived',
+      archiveFailed: 'Bulk archive failed',
+      archivePartial: '{{ok}} spools archived, {{failed}} failed',
+      archiveAllFailed: 'All {{count}} spool archives failed — selection kept so you can retry',
+      restoreSuccess: '{{count}} spools restored',
+      restoreFailed: 'Bulk restore failed',
+      restorePartial: '{{ok}} spools restored, {{failed}} failed',
+      restoreAllFailed: 'All {{count}} spool restores failed — selection kept so you can retry',
+      invalidHex: 'Enter 6 hex characters (RRGGBB) or 8 (RRGGBBAA). The field will not be applied otherwise.',
+    },
     spoolmanMixedContentTitle: 'Spoolman can\'t load over HTTPS — mixed-content blocked by your browser',
     spoolmanMixedContentBody: 'Bambuddy is served over HTTPS (via your reverse proxy), but your Spoolman URL is still plain HTTP. Browsers block mixed content for security, so the embedded Spoolman UI can\'t render. Spoolman needs to be reachable over HTTPS for this to work.',
     spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',

+ 46 - 0
frontend/src/i18n/locales/es.ts

@@ -3853,6 +3853,52 @@ export default {
     unknownSpoolTitle: 'Nuevo filamento detectado',
     unknownSpoolMessage: 'Se ha detectado una bobina con una etiqueta RFID desconocida en {{location}}. ¿Añadirla a su inventario ahora?',
     unknownSpoolSlot: 'Ranura',
+    bulk: {
+      selectAllVisible: 'Seleccionar todo lo visible',
+      selectRow: 'Seleccionar fila',
+      selectGroup: 'Seleccionar grupo',
+      selectionCount: '{{count}} seleccionadas',
+      edit: 'Editar',
+      printLabels: 'Imprimir etiquetas',
+      resetUsage: 'Restablecer uso',
+      restore: 'Restaurar',
+      archive: 'Archivar',
+      delete: 'Eliminar',
+      clearSelection: 'Limpiar selección',
+      editTitle: 'Edición masiva de bobinas',
+      editSubtitle: 'Se aplica a {{count}} bobinas seleccionadas. Solo se actualizan los campos marcados.',
+      editHint: 'Escriba en un campo para marcarlo para actualización — solo se envían las filas marcadas. Los campos vacíos no cambian las bobinas (vaciar campos solo se hace por bobina).',
+      useCustom: 'Usar "{{value}}"',
+      toggleField: 'Activar la actualización de este campo',
+      changeCount: 'Se actualizarán {{count}} campos.',
+      applyPending: 'Aplicando...',
+      applyButton: 'Aplicar a {{count}} bobinas',
+      deleteTitle: 'Eliminar bobinas seleccionadas',
+      archiveTitle: 'Archivar bobinas seleccionadas',
+      restoreTitle: 'Restaurar bobinas seleccionadas',
+      resetUsageTitle: 'Restablecer uso de bobinas seleccionadas',
+      deleteMessage: '¿Eliminar permanentemente {{count}} bobinas? Esto no se puede deshacer.',
+      archiveMessage: '¿Archivar {{count}} bobinas? Se pueden restaurar más tarde.',
+      restoreMessage: '¿Restaurar {{count}} bobinas archivadas?',
+      resetUsageMessage: '¿Restablecer el contador "Total consumido" en {{count}} bobinas? El peso restante se conserva.',
+      updateSuccess: '{{count}} bobinas actualizadas',
+      updateFailed: 'Actualización masiva fallida',
+      updatePartial: '{{ok}} bobinas actualizadas, {{failed}} fallidas',
+      updateAllFailed: 'Las {{count}} actualizaciones fallaron — la selección se mantiene para reintentar',
+      deleteSuccess: '{{count}} bobinas eliminadas',
+      deleteFailed: 'Eliminación masiva fallida',
+      deletePartial: '{{ok}} bobinas eliminadas, {{failed}} fallidas',
+      deleteAllFailed: 'Las {{count}} eliminaciones fallaron — la selección se mantiene para reintentar',
+      archiveSuccess: '{{count}} bobinas archivadas',
+      archiveFailed: 'Archivado masivo fallido',
+      archivePartial: '{{ok}} bobinas archivadas, {{failed}} fallidas',
+      archiveAllFailed: 'Los {{count}} archivados fallaron — la selección se mantiene para reintentar',
+      restoreSuccess: '{{count}} bobinas restauradas',
+      restoreFailed: 'Restauración masiva fallida',
+      restorePartial: '{{ok}} bobinas restauradas, {{failed}} fallidas',
+      restoreAllFailed: 'Las {{count}} restauraciones fallaron — la selección se mantiene para reintentar',
+      invalidHex: 'Introduzca 6 caracteres hex (RRGGBB) u 8 (RRGGBBAA). De lo contrario el campo no se aplicará.',
+    },
     spoolmanMixedContentTitle: 'Spoolman no se puede cargar por HTTPS — contenido mixto bloqueado por su navegador',
     spoolmanMixedContentBody: 'Bambuddy se sirve por HTTPS (mediante su proxy inverso), pero su URL de Spoolman sigue siendo HTTP sin cifrar. Los navegadores bloquean el contenido mixto por seguridad, por lo que la interfaz integrada de Spoolman no se puede mostrar. Spoolman debe ser accesible por HTTPS para que esto funcione.',
     spoolmanMixedContentFixReverseProxy: 'Ponga Spoolman tras el mismo proxy inverso que Bambuddy (Traefik / Nginx / Caddy) con HTTPS y luego actualice la URL de Spoolman en Ajustes a la nueva dirección HTTPS.',

+ 46 - 0
frontend/src/i18n/locales/fr.ts

@@ -3839,6 +3839,52 @@ export default {
     unknownSpoolTitle: 'Nouveau filament détecté',
     unknownSpoolMessage: 'Une bobine avec un tag RFID inconnu a été détectée à {{location}}. L\'ajouter à votre inventaire maintenant ?',
     unknownSpoolSlot: 'Emplacement',
+    bulk: {
+      selectAllVisible: 'Tout sélectionner (visible)',
+      selectRow: 'Sélectionner la ligne',
+      selectGroup: 'Sélectionner le groupe',
+      selectionCount: '{{count}} sélectionnée(s)',
+      edit: 'Modifier',
+      printLabels: 'Imprimer les étiquettes',
+      resetUsage: 'Réinitialiser l\'utilisation',
+      restore: 'Restaurer',
+      archive: 'Archiver',
+      delete: 'Supprimer',
+      clearSelection: 'Effacer la sélection',
+      editTitle: 'Modification groupée des bobines',
+      editSubtitle: 'S\'applique à {{count}} bobines sélectionnées. Seuls les champs cochés seront mis à jour.',
+      editHint: 'Tapez dans un champ pour le marquer comme à mettre à jour — seules les lignes cochées sont envoyées. Un champ laissé vide laisse les bobines inchangées (vider un champ se fait par bobine).',
+      useCustom: 'Utiliser « {{value}} »',
+      toggleField: 'Activer la mise à jour de ce champ',
+      changeCount: '{{count}} champ(s) seront mis à jour.',
+      applyPending: 'Application...',
+      applyButton: 'Appliquer à {{count}} bobines',
+      deleteTitle: 'Supprimer les bobines sélectionnées',
+      archiveTitle: 'Archiver les bobines sélectionnées',
+      restoreTitle: 'Restaurer les bobines sélectionnées',
+      resetUsageTitle: 'Réinitialiser l\'utilisation des bobines sélectionnées',
+      deleteMessage: 'Supprimer définitivement {{count}} bobines ? Cette action est irréversible.',
+      archiveMessage: 'Archiver {{count}} bobines ? Elles pourront être restaurées plus tard.',
+      restoreMessage: 'Restaurer {{count}} bobines archivées ?',
+      resetUsageMessage: 'Réinitialiser le compteur « Total consommé » sur {{count}} bobines ? Le poids restant est préservé.',
+      updateSuccess: '{{count}} bobines mises à jour',
+      updateFailed: 'Mise à jour groupée échouée',
+      updatePartial: '{{ok}} bobines mises à jour, {{failed}} échouées',
+      updateAllFailed: 'Les {{count}} mises à jour ont échoué — la sélection est conservée pour réessayer',
+      deleteSuccess: '{{count}} bobines supprimées',
+      deleteFailed: 'Suppression groupée échouée',
+      deletePartial: '{{ok}} bobines supprimées, {{failed}} échouées',
+      deleteAllFailed: 'Les {{count}} suppressions ont échoué — la sélection est conservée pour réessayer',
+      archiveSuccess: '{{count}} bobines archivées',
+      archiveFailed: 'Archivage groupé échoué',
+      archivePartial: '{{ok}} bobines archivées, {{failed}} échouées',
+      archiveAllFailed: 'Les {{count}} archivages ont échoué — la sélection est conservée pour réessayer',
+      restoreSuccess: '{{count}} bobines restaurées',
+      restoreFailed: 'Restauration groupée échouée',
+      restorePartial: '{{ok}} bobines restaurées, {{failed}} échouées',
+      restoreAllFailed: 'Les {{count}} restaurations ont échoué — la sélection est conservée pour réessayer',
+      invalidHex: 'Saisissez 6 caractères hex (RRVVBB) ou 8 (RRVVBBAA). Sinon le champ ne sera pas appliqué.',
+    },
     spoolmanMixedContentTitle: 'Spoolman ne peut pas se charger en HTTPS — contenu mixte bloqué par votre navigateur',
     spoolmanMixedContentBody: 'Bambuddy est servi en HTTPS (via votre reverse proxy), mais votre URL Spoolman est encore en HTTP. Les navigateurs bloquent le contenu mixte pour des raisons de sécurité, donc l\'interface Spoolman intégrée ne peut pas s\'afficher. Spoolman doit être accessible en HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',

+ 46 - 0
frontend/src/i18n/locales/it.ts

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: 'Nuovo filamento rilevato',
     unknownSpoolMessage: 'È stata rilevata una bobina con tag RFID sconosciuto in {{location}}. Aggiungerla all\'inventario ora?',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Seleziona tutto il visibile',
+      selectRow: 'Seleziona riga',
+      selectGroup: 'Seleziona gruppo',
+      selectionCount: '{{count}} selezionate',
+      edit: 'Modifica',
+      printLabels: 'Stampa etichette',
+      resetUsage: 'Reimposta utilizzo',
+      restore: 'Ripristina',
+      archive: 'Archivia',
+      delete: 'Elimina',
+      clearSelection: 'Cancella selezione',
+      editTitle: 'Modifica bobine in blocco',
+      editSubtitle: 'Si applica a {{count}} bobine selezionate. Vengono aggiornati solo i campi spuntati.',
+      editHint: 'Digita in un campo per contrassegnarlo per l\'aggiornamento — vengono inviate solo le righe spuntate. Un campo vuoto lascia le bobine invariate (svuotare un campo si fa per singola bobina).',
+      useCustom: 'Usa "{{value}}"',
+      toggleField: 'Attiva l\'aggiornamento per questo campo',
+      changeCount: '{{count}} campi verranno aggiornati.',
+      applyPending: 'Applicazione in corso...',
+      applyButton: 'Applica a {{count}} bobine',
+      deleteTitle: 'Elimina bobine selezionate',
+      archiveTitle: 'Archivia bobine selezionate',
+      restoreTitle: 'Ripristina bobine selezionate',
+      resetUsageTitle: 'Reimposta utilizzo delle bobine selezionate',
+      deleteMessage: 'Eliminare definitivamente {{count}} bobine? L\'operazione non può essere annullata.',
+      archiveMessage: 'Archiviare {{count}} bobine? Possono essere ripristinate in seguito.',
+      restoreMessage: 'Ripristinare {{count}} bobine archiviate?',
+      resetUsageMessage: 'Reimpostare il contatore "Totale consumato" su {{count}} bobine? Il peso rimanente viene preservato.',
+      updateSuccess: '{{count}} bobine aggiornate',
+      updateFailed: 'Aggiornamento in blocco fallito',
+      updatePartial: '{{ok}} bobine aggiornate, {{failed}} fallite',
+      updateAllFailed: 'Tutti i {{count}} aggiornamenti sono falliti — la selezione viene mantenuta per riprovare',
+      deleteSuccess: '{{count}} bobine eliminate',
+      deleteFailed: 'Eliminazione in blocco fallita',
+      deletePartial: '{{ok}} bobine eliminate, {{failed}} fallite',
+      deleteAllFailed: 'Tutte le {{count}} eliminazioni sono fallite — la selezione viene mantenuta per riprovare',
+      archiveSuccess: '{{count}} bobine archiviate',
+      archiveFailed: 'Archiviazione in blocco fallita',
+      archivePartial: '{{ok}} bobine archiviate, {{failed}} fallite',
+      archiveAllFailed: 'Tutte le {{count}} archiviazioni sono fallite — la selezione viene mantenuta per riprovare',
+      restoreSuccess: '{{count}} bobine ripristinate',
+      restoreFailed: 'Ripristino in blocco fallito',
+      restorePartial: '{{ok}} bobine ripristinate, {{failed}} fallite',
+      restoreAllFailed: 'Tutti i {{count}} ripristini sono falliti — la selezione viene mantenuta per riprovare',
+      invalidHex: 'Inserisci 6 caratteri esadecimali (RRGGBB) o 8 (RRGGBBAA). Altrimenti il campo non verrà applicato.',
+    },
     spoolmanMixedContentTitle: 'Spoolman non può essere caricato tramite HTTPS — contenuto misto bloccato dal browser',
     spoolmanMixedContentBody: 'Bambuddy viene servito tramite HTTPS (dietro il tuo reverse proxy), ma l\'URL di Spoolman è ancora HTTP. I browser bloccano il contenuto misto per motivi di sicurezza, quindi l\'interfaccia Spoolman incorporata non può essere visualizzata. Anche Spoolman deve essere raggiungibile via HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',

+ 46 - 0
frontend/src/i18n/locales/ja.ts

@@ -3850,6 +3850,52 @@ export default {
     unknownSpoolTitle: '新しいフィラメントを検出',
     unknownSpoolMessage: '{{location}} で不明なRFIDタグのスプールが検出されました。今すぐ在庫に追加しますか?',
     unknownSpoolSlot: 'スロット',
+    bulk: {
+      selectAllVisible: '表示中をすべて選択',
+      selectRow: '行を選択',
+      selectGroup: 'グループを選択',
+      selectionCount: '{{count}} 件選択中',
+      edit: '編集',
+      printLabels: 'ラベルを印刷',
+      resetUsage: '使用量をリセット',
+      restore: '復元',
+      archive: 'アーカイブ',
+      delete: '削除',
+      clearSelection: '選択を解除',
+      editTitle: 'スプールを一括編集',
+      editSubtitle: '選択中の {{count}} 件に適用されます。チェックを入れた項目のみ更新されます。',
+      editHint: '項目に入力すると更新対象としてマークされます。チェック済みの行のみ送信されます。空のままだとスプールは変更されません(項目を空にする操作はスプール個別の編集で行ってください)。',
+      useCustom: '"{{value}}" を使用',
+      toggleField: 'この項目の更新を切り替え',
+      changeCount: '{{count}} 件の項目が更新されます。',
+      applyPending: '適用中...',
+      applyButton: '{{count}} 件に適用',
+      deleteTitle: '選択したスプールを削除',
+      archiveTitle: '選択したスプールをアーカイブ',
+      restoreTitle: '選択したスプールを復元',
+      resetUsageTitle: '選択したスプールの使用量をリセット',
+      deleteMessage: '{{count}} 件のスプールを完全に削除しますか?元に戻せません。',
+      archiveMessage: '{{count}} 件のスプールをアーカイブしますか?後で復元できます。',
+      restoreMessage: '{{count}} 件のアーカイブ済みスプールを復元しますか?',
+      resetUsageMessage: '{{count}} 件のスプールの「累計使用量」カウンターをリセットしますか?残量は保持されます。',
+      updateSuccess: '{{count}} 件のスプールを更新しました',
+      updateFailed: '一括更新に失敗しました',
+      updatePartial: '{{ok}} 件更新、{{failed}} 件失敗',
+      updateAllFailed: '{{count}} 件すべての更新に失敗しました — 再試行のため選択は維持されます',
+      deleteSuccess: '{{count}} 件のスプールを削除しました',
+      deleteFailed: '一括削除に失敗しました',
+      deletePartial: '{{ok}} 件削除、{{failed}} 件失敗',
+      deleteAllFailed: '{{count}} 件すべての削除に失敗しました — 再試行のため選択は維持されます',
+      archiveSuccess: '{{count}} 件のスプールをアーカイブしました',
+      archiveFailed: '一括アーカイブに失敗しました',
+      archivePartial: '{{ok}} 件アーカイブ、{{failed}} 件失敗',
+      archiveAllFailed: '{{count}} 件すべてのアーカイブに失敗しました — 再試行のため選択は維持されます',
+      restoreSuccess: '{{count}} 件のスプールを復元しました',
+      restoreFailed: '一括復元に失敗しました',
+      restorePartial: '{{ok}} 件復元、{{failed}} 件失敗',
+      restoreAllFailed: '{{count}} 件すべての復元に失敗しました — 再試行のため選択は維持されます',
+      invalidHex: '16進数 6 文字(RRGGBB)または 8 文字(RRGGBBAA)を入力してください。それ以外の場合この項目は適用されません。',
+    },
     spoolmanMixedContentTitle: 'Spoolman を HTTPS で読み込めません — ブラウザが混在コンテンツをブロックしています',
     spoolmanMixedContentBody: 'Bambuddy はリバースプロキシ経由で HTTPS 配信されていますが、Spoolman の URL は HTTP のままです。ブラウザはセキュリティ上の理由で混在コンテンツをブロックするため、埋め込みの Spoolman UI を表示できません。Spoolman も HTTPS でアクセスできる必要があります。',
     spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',

+ 46 - 0
frontend/src/i18n/locales/ko.ts

@@ -3640,6 +3640,52 @@ export default {
     unknownSpoolTitle: '새 필라멘트 감지됨',
     unknownSpoolMessage: '{{location}}에서 알 수 없는 RFID 태그가 있는 스풀이 감지되었습니다. 지금 인벤토리에 추가하시겠습니까?',
     unknownSpoolSlot: '슬롯',
+    bulk: {
+      selectAllVisible: '표시된 항목 모두 선택',
+      selectRow: '행 선택',
+      selectGroup: '그룹 선택',
+      selectionCount: '{{count}}개 선택됨',
+      edit: '편집',
+      printLabels: '라벨 인쇄',
+      resetUsage: '사용량 재설정',
+      restore: '복원',
+      archive: '보관',
+      delete: '삭제',
+      clearSelection: '선택 해제',
+      editTitle: '스풀 일괄 편집',
+      editSubtitle: '선택한 {{count}}개의 스풀에 적용됩니다. 체크한 필드만 업데이트됩니다.',
+      editHint: '필드에 입력하면 업데이트할 항목으로 표시됩니다 — 체크된 행만 전송됩니다. 빈 필드는 스풀을 변경하지 않습니다 (필드 비우기는 개별 스풀에서만 가능).',
+      useCustom: '"{{value}}" 사용',
+      toggleField: '이 필드의 업데이트 전환',
+      changeCount: '{{count}}개의 필드가 업데이트됩니다.',
+      applyPending: '적용 중...',
+      applyButton: '{{count}}개에 적용',
+      deleteTitle: '선택한 스풀 삭제',
+      archiveTitle: '선택한 스풀 보관',
+      restoreTitle: '선택한 스풀 복원',
+      resetUsageTitle: '선택한 스풀의 사용량 재설정',
+      deleteMessage: '{{count}}개의 스풀을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.',
+      archiveMessage: '{{count}}개의 스풀을 보관하시겠습니까? 나중에 복원할 수 있습니다.',
+      restoreMessage: '{{count}}개의 보관된 스풀을 복원하시겠습니까?',
+      resetUsageMessage: '{{count}}개의 스풀에서 "총 사용량" 카운터를 재설정하시겠습니까? 남은 무게는 보존됩니다.',
+      updateSuccess: '{{count}}개의 스풀 업데이트됨',
+      updateFailed: '일괄 업데이트 실패',
+      updatePartial: '{{ok}}개 업데이트, {{failed}}개 실패',
+      updateAllFailed: '{{count}}개의 업데이트가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      deleteSuccess: '{{count}}개의 스풀 삭제됨',
+      deleteFailed: '일괄 삭제 실패',
+      deletePartial: '{{ok}}개 삭제, {{failed}}개 실패',
+      deleteAllFailed: '{{count}}개의 삭제가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      archiveSuccess: '{{count}}개의 스풀 보관됨',
+      archiveFailed: '일괄 보관 실패',
+      archivePartial: '{{ok}}개 보관, {{failed}}개 실패',
+      archiveAllFailed: '{{count}}개의 보관이 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      restoreSuccess: '{{count}}개의 스풀 복원됨',
+      restoreFailed: '일괄 복원 실패',
+      restorePartial: '{{ok}}개 복원, {{failed}}개 실패',
+      restoreAllFailed: '{{count}}개의 복원이 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      invalidHex: '16진수 6자(RRGGBB) 또는 8자(RRGGBBAA)를 입력하세요. 그렇지 않으면 필드가 적용되지 않습니다.',
+    },
     spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
     spoolmanMixedContentBody: 'Bambuddy가 HTTPS로 서비스되고 있지만 Spoolman URL은 여전히 HTTP입니다. 브라우저는 보안상 혼합 콘텐츠를 차단하므로 내장된 Spoolman UI가 렌더링되지 않습니다. 이 기능이 작동하려면 Spoolman이 HTTPS로 접근 가능해야 합니다.',
     spoolmanMixedContentFixReverseProxy: 'Spoolman을 Bambuddy와 같은 리버스 프록시(Traefik / Nginx / Caddy) 뒤에 HTTPS로 배치한 다음 설정에서 Spoolman URL을 새 HTTPS 주소로 업데이트하세요.',

+ 46 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: 'Novo filamento detectado',
     unknownSpoolMessage: 'Um carretel com tag RFID desconhecido foi detectado em {{location}}. Adicioná-lo ao seu inventário agora?',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Selecionar todos os visíveis',
+      selectRow: 'Selecionar linha',
+      selectGroup: 'Selecionar grupo',
+      selectionCount: '{{count}} selecionados',
+      edit: 'Editar',
+      printLabels: 'Imprimir etiquetas',
+      resetUsage: 'Redefinir uso',
+      restore: 'Restaurar',
+      archive: 'Arquivar',
+      delete: 'Excluir',
+      clearSelection: 'Limpar seleção',
+      editTitle: 'Editar carretéis em massa',
+      editSubtitle: 'Aplica-se a {{count}} carretéis selecionados. Apenas os campos marcados serão atualizados.',
+      editHint: 'Digite em um campo para marcá-lo para atualização — apenas linhas marcadas são enviadas. Deixar um campo vazio mantém os carretéis inalterados (limpar campos só é feito por carretel).',
+      useCustom: 'Usar "{{value}}"',
+      toggleField: 'Ativar atualização deste campo',
+      changeCount: '{{count}} campos serão atualizados.',
+      applyPending: 'Aplicando...',
+      applyButton: 'Aplicar a {{count}} carretéis',
+      deleteTitle: 'Excluir carretéis selecionados',
+      archiveTitle: 'Arquivar carretéis selecionados',
+      restoreTitle: 'Restaurar carretéis selecionados',
+      resetUsageTitle: 'Redefinir uso dos carretéis selecionados',
+      deleteMessage: 'Excluir permanentemente {{count}} carretéis? Esta ação não pode ser desfeita.',
+      archiveMessage: 'Arquivar {{count}} carretéis? Eles podem ser restaurados depois.',
+      restoreMessage: 'Restaurar {{count}} carretéis arquivados?',
+      resetUsageMessage: 'Redefinir o contador "Total Consumido" em {{count}} carretéis? O peso restante é preservado.',
+      updateSuccess: '{{count}} carretéis atualizados',
+      updateFailed: 'Atualização em massa falhou',
+      updatePartial: '{{ok}} carretéis atualizados, {{failed}} falharam',
+      updateAllFailed: 'Todas as {{count}} atualizações falharam — a seleção é mantida para nova tentativa',
+      deleteSuccess: '{{count}} carretéis excluídos',
+      deleteFailed: 'Exclusão em massa falhou',
+      deletePartial: '{{ok}} carretéis excluídos, {{failed}} falharam',
+      deleteAllFailed: 'Todas as {{count}} exclusões falharam — a seleção é mantida para nova tentativa',
+      archiveSuccess: '{{count}} carretéis arquivados',
+      archiveFailed: 'Arquivamento em massa falhou',
+      archivePartial: '{{ok}} carretéis arquivados, {{failed}} falharam',
+      archiveAllFailed: 'Todos os {{count}} arquivamentos falharam — a seleção é mantida para nova tentativa',
+      restoreSuccess: '{{count}} carretéis restaurados',
+      restoreFailed: 'Restauração em massa falhou',
+      restorePartial: '{{ok}} carretéis restaurados, {{failed}} falharam',
+      restoreAllFailed: 'Todas as {{count}} restaurações falharam — a seleção é mantida para nova tentativa',
+      invalidHex: 'Digite 6 caracteres hex (RRGGBB) ou 8 (RRGGBBAA). Caso contrário o campo não será aplicado.',
+    },
     spoolmanMixedContentTitle: 'Spoolman não pode carregar em HTTPS — conteúdo misto bloqueado pelo navegador',
     spoolmanMixedContentBody: 'O Bambuddy é servido via HTTPS (pelo seu reverse proxy), mas a URL do Spoolman ainda é HTTP. Os navegadores bloqueiam conteúdo misto por segurança, então a interface embutida do Spoolman não consegue carregar. O Spoolman também precisa estar acessível via HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',

+ 46 - 0
frontend/src/i18n/locales/tr.ts

@@ -3839,6 +3839,52 @@ export default {
     unknownSpoolTitle: 'Yeni filament algılandı',
     unknownSpoolMessage: '{{location}} konumunda bilinmeyen RFID etiketli bir makara algılandı. Şimdi envantere eklensin mi?',
     unknownSpoolSlot: 'Yuva',
+    bulk: {
+      selectAllVisible: 'Görünenleri seç',
+      selectRow: 'Satırı seç',
+      selectGroup: 'Grubu seç',
+      selectionCount: '{{count}} seçildi',
+      edit: 'Düzenle',
+      printLabels: 'Etiket yazdır',
+      resetUsage: 'Kullanımı sıfırla',
+      restore: 'Geri yükle',
+      archive: 'Arşivle',
+      delete: 'Sil',
+      clearSelection: 'Seçimi temizle',
+      editTitle: 'Makaraları toplu düzenle',
+      editSubtitle: 'Seçilen {{count}} makaraya uygulanır. Yalnızca işaretlediğiniz alanlar güncellenir.',
+      editHint: 'Bir alana yazmak onu güncelleme için işaretler — yalnızca işaretli satırlar gönderilir. Boş bırakılan alanlar makaraları değiştirmez (alanları boşaltmak yalnızca makara bazında yapılabilir).',
+      useCustom: '"{{value}}" kullan',
+      toggleField: 'Bu alanın güncellenmesini değiştir',
+      changeCount: '{{count}} alan güncellenecek.',
+      applyPending: 'Uygulanıyor...',
+      applyButton: '{{count}} makaraya uygula',
+      deleteTitle: 'Seçili makaraları sil',
+      archiveTitle: 'Seçili makaraları arşivle',
+      restoreTitle: 'Seçili makaraları geri yükle',
+      resetUsageTitle: 'Seçili makaraların kullanımını sıfırla',
+      deleteMessage: '{{count}} makara kalıcı olarak silinsin mi? Bu işlem geri alınamaz.',
+      archiveMessage: '{{count}} makara arşivlensin mi? Daha sonra geri yüklenebilir.',
+      restoreMessage: '{{count}} arşivlenmiş makara geri yüklensin mi?',
+      resetUsageMessage: '{{count}} makaranın "Toplam Tüketim" sayacı sıfırlansın mı? Kalan ağırlık korunur.',
+      updateSuccess: '{{count}} makara güncellendi',
+      updateFailed: 'Toplu güncelleme başarısız',
+      updatePartial: '{{ok}} makara güncellendi, {{failed}} başarısız',
+      updateAllFailed: '{{count}} güncellemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      deleteSuccess: '{{count}} makara silindi',
+      deleteFailed: 'Toplu silme başarısız',
+      deletePartial: '{{ok}} makara silindi, {{failed}} başarısız',
+      deleteAllFailed: '{{count}} silmenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      archiveSuccess: '{{count}} makara arşivlendi',
+      archiveFailed: 'Toplu arşivleme başarısız',
+      archivePartial: '{{ok}} makara arşivlendi, {{failed}} başarısız',
+      archiveAllFailed: '{{count}} arşivlemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      restoreSuccess: '{{count}} makara geri yüklendi',
+      restoreFailed: 'Toplu geri yükleme başarısız',
+      restorePartial: '{{ok}} makara geri yüklendi, {{failed}} başarısız',
+      restoreAllFailed: '{{count}} geri yüklemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      invalidHex: '6 hex karakter (RRGGBB) veya 8 (RRGGBBAA) girin. Aksi takdirde alan uygulanmaz.',
+    },
     spoolmanMixedContentTitle: 'Spoolman HTTPS üzerinden yüklenemiyor — tarayıcınız tarafından karışık içerik engellendi',
     spoolmanMixedContentBody: 'Bambuddy HTTPS üzerinden sunuluyor (ters proxy\'niz aracılığıyla), ancak Spoolman URL\'niz hâlâ düz HTTP. Tarayıcılar güvenlik için karışık içeriği engeller, bu nedenle gömülü Spoolman arayüzü oluşturulamaz. Bunun çalışması için Spoolman\'in HTTPS üzerinden erişilebilir olması gerekiyor.',
     spoolmanMixedContentFixReverseProxy: "Spoolman'i Bambuddy ile aynı ters proxy'nin (Traefik / Nginx / Caddy) arkasına HTTPS ile koyun, ardından Ayarlardaki Spoolman URL'sini yeni HTTPS adresine güncelleyin.",

+ 46 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: '检测到新耗材',
     unknownSpoolMessage: '在 {{location}} 检测到带有未知 RFID 标签的料盘。是否立即添加到库存?',
     unknownSpoolSlot: '槽位',
+    bulk: {
+      selectAllVisible: '选择所有可见项',
+      selectRow: '选择行',
+      selectGroup: '选择组',
+      selectionCount: '已选 {{count}} 个',
+      edit: '编辑',
+      printLabels: '打印标签',
+      resetUsage: '重置使用量',
+      restore: '恢复',
+      archive: '归档',
+      delete: '删除',
+      clearSelection: '清除选择',
+      editTitle: '批量编辑料盘',
+      editSubtitle: '将应用于选中的 {{count}} 个料盘。仅更新勾选的字段。',
+      editHint: '在字段中输入即可标记为更新 — 仅发送已勾选的行。留空字段不会更改料盘(清空字段仅能在单个料盘中操作)。',
+      useCustom: '使用 "{{value}}"',
+      toggleField: '切换此字段的更新',
+      changeCount: '将更新 {{count}} 个字段。',
+      applyPending: '应用中...',
+      applyButton: '应用于 {{count}} 个料盘',
+      deleteTitle: '删除选中的料盘',
+      archiveTitle: '归档选中的料盘',
+      restoreTitle: '恢复选中的料盘',
+      resetUsageTitle: '重置选中料盘的使用量',
+      deleteMessage: '永久删除 {{count}} 个料盘?此操作无法撤销。',
+      archiveMessage: '归档 {{count}} 个料盘?以后可以恢复。',
+      restoreMessage: '恢复 {{count}} 个已归档的料盘?',
+      resetUsageMessage: '将 {{count}} 个料盘的"总消耗"计数器重置?剩余重量将被保留。',
+      updateSuccess: '已更新 {{count}} 个料盘',
+      updateFailed: '批量更新失败',
+      updatePartial: '已更新 {{ok}} 个,{{failed}} 个失败',
+      updateAllFailed: '{{count}} 个料盘更新全部失败 — 已保留选择以便重试',
+      deleteSuccess: '已删除 {{count}} 个料盘',
+      deleteFailed: '批量删除失败',
+      deletePartial: '已删除 {{ok}} 个,{{failed}} 个失败',
+      deleteAllFailed: '{{count}} 个料盘删除全部失败 — 已保留选择以便重试',
+      archiveSuccess: '已归档 {{count}} 个料盘',
+      archiveFailed: '批量归档失败',
+      archivePartial: '已归档 {{ok}} 个,{{failed}} 个失败',
+      archiveAllFailed: '{{count}} 个料盘归档全部失败 — 已保留选择以便重试',
+      restoreSuccess: '已恢复 {{count}} 个料盘',
+      restoreFailed: '批量恢复失败',
+      restorePartial: '已恢复 {{ok}} 个,{{failed}} 个失败',
+      restoreAllFailed: '{{count}} 个料盘恢复全部失败 — 已保留选择以便重试',
+      invalidHex: '请输入 6 位十六进制字符 (RRGGBB) 或 8 位 (RRGGBBAA),否则该字段不会应用。',
+    },
     spoolmanMixedContentTitle: 'Spoolman 无法通过 HTTPS 加载 — 浏览器已阻止混合内容',
     spoolmanMixedContentBody: 'Bambuddy 通过您的反向代理以 HTTPS 提供服务,但您的 Spoolman 地址仍为 HTTP。出于安全考虑,浏览器会阻止混合内容,因此嵌入式 Spoolman 界面无法加载。Spoolman 也必须通过 HTTPS 访问。',
     spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',

+ 46 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: '偵測到新耗材',
     unknownSpoolMessage: '在 {{location}} 偵測到具有未知 RFID 標籤的料盤。是否立即新增至庫存?',
     unknownSpoolSlot: '插槽',
+    bulk: {
+      selectAllVisible: '選擇所有可見項',
+      selectRow: '選擇列',
+      selectGroup: '選擇群組',
+      selectionCount: '已選 {{count}} 個',
+      edit: '編輯',
+      printLabels: '列印標籤',
+      resetUsage: '重設使用量',
+      restore: '還原',
+      archive: '封存',
+      delete: '刪除',
+      clearSelection: '清除選擇',
+      editTitle: '批次編輯料盤',
+      editSubtitle: '將套用於選取的 {{count}} 個料盤。僅更新勾選的欄位。',
+      editHint: '在欄位中輸入即可標記為更新 — 僅傳送已勾選的列。留空欄位不會變更料盤(清空欄位僅能在單個料盤中操作)。',
+      useCustom: '使用「{{value}}」',
+      toggleField: '切換此欄位的更新',
+      changeCount: '將更新 {{count}} 個欄位。',
+      applyPending: '套用中...',
+      applyButton: '套用於 {{count}} 個料盤',
+      deleteTitle: '刪除選取的料盤',
+      archiveTitle: '封存選取的料盤',
+      restoreTitle: '還原選取的料盤',
+      resetUsageTitle: '重設選取料盤的使用量',
+      deleteMessage: '永久刪除 {{count}} 個料盤?此操作無法復原。',
+      archiveMessage: '封存 {{count}} 個料盤?之後可以還原。',
+      restoreMessage: '還原 {{count}} 個已封存的料盤?',
+      resetUsageMessage: '重設 {{count}} 個料盤的「總消耗」計數器?剩餘重量將被保留。',
+      updateSuccess: '已更新 {{count}} 個料盤',
+      updateFailed: '批次更新失敗',
+      updatePartial: '已更新 {{ok}} 個,{{failed}} 個失敗',
+      updateAllFailed: '{{count}} 個料盤更新全部失敗 — 已保留選擇以便重試',
+      deleteSuccess: '已刪除 {{count}} 個料盤',
+      deleteFailed: '批次刪除失敗',
+      deletePartial: '已刪除 {{ok}} 個,{{failed}} 個失敗',
+      deleteAllFailed: '{{count}} 個料盤刪除全部失敗 — 已保留選擇以便重試',
+      archiveSuccess: '已封存 {{count}} 個料盤',
+      archiveFailed: '批次封存失敗',
+      archivePartial: '已封存 {{ok}} 個,{{failed}} 個失敗',
+      archiveAllFailed: '{{count}} 個料盤封存全部失敗 — 已保留選擇以便重試',
+      restoreSuccess: '已還原 {{count}} 個料盤',
+      restoreFailed: '批次還原失敗',
+      restorePartial: '已還原 {{ok}} 個,{{failed}} 個失敗',
+      restoreAllFailed: '{{count}} 個料盤還原全部失敗 — 已保留選擇以便重試',
+      invalidHex: '請輸入 6 位十六進位字元 (RRGGBB) 或 8 位 (RRGGBBAA),否則此欄位不會套用。',
+    },
     spoolmanMixedContentTitle: 'Spoolman 無法透過 HTTPS 載入 — 瀏覽器已封鎖混合內容',
     spoolmanMixedContentBody: 'Bambuddy 透過您的反向代理以 HTTPS 提供服務,但您的 Spoolman 位址仍為 HTTP。基於安全考量,瀏覽器會封鎖混合內容,因此內嵌的 Spoolman 介面無法載入。Spoolman 也必須可透過 HTTPS 存取。',
     spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',

+ 326 - 2
frontend/src/pages/InventoryPage.tsx

@@ -21,6 +21,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { LocationsModal } from '../components/LocationsModal';
+import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { resolveSpoolColorName } from '../utils/colors';
@@ -44,6 +45,17 @@ type DisplayItem =
   | { type: 'single'; spool: InventorySpool }
   | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
 
+function dedupeAndSort(values: Array<string | null | undefined>): string[] {
+  const set = new Set<string>();
+  for (const v of values) {
+    if (typeof v === 'string') {
+      const trimmed = v.trim();
+      if (trimmed) set.add(trimmed);
+    }
+  }
+  return Array.from(set).sort((a, b) => a.localeCompare(b));
+}
+
 function spoolGroupKey(s: InventorySpool): string {
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // not collapse two spools that share the base colour but differ on
@@ -505,6 +517,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   });
   const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
 
+  // Bulk-selection state for batch actions on the spool list (#1795).
+  // Cleared when the user switches filter/tab/page because cross-page selection
+  // produces a confusing toolbar count vs. visible-row count delta.
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [bulkEditOpen, setBulkEditOpen] = useState(false);
+  const [bulkConfirmAction, setBulkConfirmAction] = useState<'delete' | 'archive' | 'restore' | 'reset-consumed-counter' | null>(null);
+  const toggleSelected = useCallback((id: number) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }, []);
+  const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
+
+  // Clear selection on any filter/tab change so the toolbar count stays
+  // honest vs. what the user is actually looking at.
+  useEffect(() => {
+    setSelectedIds(new Set());
+  }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search]);
+
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
   const [pageSize, setPageSize] = useState(() => {
@@ -751,12 +785,127 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
       showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
+      // Close any open bulk-confirm modal + clear selection so the toolbar
+      // collapses after the action — matches the other three bulk mutations
+      // and stops the confirm dialog from lingering after Reset.
+      setBulkConfirmAction(null);
+      clearSelection();
     },
     onError: () => {
       showToast(t('inventory.resetConsumedCounterFailed'), 'error');
     },
   });
 
+  // Bulk action mutations (#1795). Each invalidates the same query keys as
+  // the per-spool equivalents so the table refreshes with the new state.
+  // Helper: count items that didn't succeed across the two response shapes
+  // (internal mode returns not_found, Spoolman returns errors[]). When the
+  // success count is 0 OR any failures occurred, surface that to the user
+  // instead of the silent green-toast-and-clear flow the first cut shipped.
+  const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
+    (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
+
+  const bulkUpdateMutation = useMutation({
+    mutationFn: async ({ ids, update }: { ids: number[]; update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>> }): Promise<{ updated: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkUpdateSpoolmanInventorySpools(ids, update);
+      return api.bulkUpdateSpools(ids, update);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.updated === 0) {
+        showToast(t('inventory.bulk.updateAllFailed', { count: failed }), 'error');
+        return; // keep modal open + selection intact so user can retry
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.updatePartial', { ok: data.updated, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.updateSuccess', { count: data.updated }), 'success');
+      }
+      setBulkEditOpen(false);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.updateFailed'), 'error');
+    },
+  });
+
+  const bulkDeleteMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ deleted: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkDeleteSpoolmanInventorySpools(ids);
+      return api.bulkDeleteSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.deleted === 0) {
+        showToast(t('inventory.bulk.deleteAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.deletePartial', { ok: data.deleted, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.deleteSuccess', { count: data.deleted }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.deleteFailed'), 'error');
+    },
+  });
+
+  const bulkArchiveMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ archived: number; already_archived?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkArchiveSpoolmanInventorySpools(ids);
+      return api.bulkArchiveSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      // already-archived rows are NOT failures — they're correctly idempotent.
+      const failed = failedCount(data);
+      if (data.archived === 0 && failed > 0) {
+        showToast(t('inventory.bulk.archiveAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.archivePartial', { ok: data.archived, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.archiveSuccess', { count: data.archived }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.archiveFailed'), 'error');
+    },
+  });
+
+  const bulkRestoreMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ restored: number; already_active?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkRestoreSpoolmanInventorySpools(ids);
+      return api.bulkRestoreSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.restored === 0 && failed > 0) {
+        showToast(t('inventory.bulk.restoreAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.restorePartial', { ok: data.restored, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.restoreSuccess', { count: data.restored }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.restoreFailed'), 'error');
+    },
+  });
+
   // Spool IDs the "Reset all usage" button bulk-targets. Includes archived
   // spools too — without them, the broadened "Total Consumed" stat (which
   // sums archived consumption per the #1390 follow-up) would stay non-zero
@@ -1660,6 +1809,54 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         )}
       </div>
 
+      {/* Bulk action toolbar (#1795). Appears as soon as at least one
+          spool is selected; sticky so it stays visible while the user
+          scrolls a long list. */}
+      {selectedIds.size > 0 && viewMode !== 'forecast' && (
+        <div className="sticky top-2 z-10 mb-4 flex items-center gap-2 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg backdrop-blur-sm">
+          <span className="text-sm text-bambu-green font-medium">
+            {t('inventory.bulk.selectionCount', { count: selectedIds.size })}
+          </span>
+          <div className="ml-auto flex flex-wrap items-center gap-2">
+            <Button size="sm" variant="secondary" onClick={() => setBulkEditOpen(true)}>
+              <Edit2 className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.edit')}
+            </Button>
+            <Button size="sm" variant="secondary" onClick={() => setLabelPickerSpoolIds([...selectedIds])}>
+              <Printer className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.printLabels')}
+            </Button>
+            <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('reset-consumed-counter')}>
+              <Eraser className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.resetUsage')}
+            </Button>
+            {archiveFilter === 'archived' ? (
+              <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('restore')}>
+                <RotateCcw className="w-3.5 h-3.5 mr-1.5" />
+                {t('inventory.bulk.restore')}
+              </Button>
+            ) : (
+              <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('archive')}>
+                <Archive className="w-3.5 h-3.5 mr-1.5" />
+                {t('inventory.bulk.archive')}
+              </Button>
+            )}
+            <Button size="sm" variant="danger" onClick={() => setBulkConfirmAction('delete')}>
+              <Trash2 className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.delete')}
+            </Button>
+            <button
+              className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
+              onClick={clearSelection}
+              title={t('inventory.bulk.clearSelection')}
+              aria-label={t('inventory.bulk.clearSelection')}
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+        </div>
+      )}
+
       {/* Content */}
       {isLoading ? (
         <div className="flex justify-center py-16">
@@ -1787,6 +1984,34 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
               <table className="w-full">
                 <thead>
                   <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
+                    <th className="w-10 px-3 py-3">
+                      <input
+                        type="checkbox"
+                        className="h-4 w-4 cursor-pointer"
+                        aria-label={t('inventory.bulk.selectAllVisible')}
+                        checked={pagedItems.length > 0 && pagedItems.every((item) => {
+                          const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                          return ids.every((id) => selectedIds.has(id));
+                        })}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            const next = new Set(selectedIds);
+                            for (const item of pagedItems) {
+                              const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                              for (const id of ids) next.add(id);
+                            }
+                            setSelectedIds(next);
+                          } else {
+                            const next = new Set(selectedIds);
+                            for (const item of pagedItems) {
+                              const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                              for (const id of ids) next.delete(id);
+                            }
+                            setSelectedIds(next);
+                          }
+                        }}
+                      />
+                    </th>
                     {visibleColumns.map((colId) => {
                       const sortable = !!columnSortValues[colId];
                       const isActive = sortState?.column === colId;
@@ -1833,6 +2058,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           pct={pct}
                           isExpanded={isExpanded}
                           onToggle={() => toggleGroupExpand(key)}
+                          selectedIds={selectedIds}
+                          onToggleSelected={toggleSelected}
+                          onToggleGroupSelected={(ids, select) => {
+                            setSelectedIds((prev) => {
+                              const next = new Set(prev);
+                              for (const id of ids) {
+                                if (select) next.add(id);
+                                else next.delete(id);
+                              }
+                              return next;
+                            });
+                          }}
                           onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
                           onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
@@ -1858,6 +2095,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                         spool={spool}
                         remaining={remaining}
                         pct={pct}
+                        isSelected={selectedIds.has(spool.id)}
+                        onToggleSelected={() => toggleSelected(spool.id)}
                         onEdit={() => setFormModal({ spool, mode: 'edit' })}
                         onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                         onRestore={() => restoreMutation.mutate(spool.id)}
@@ -2019,6 +2258,59 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         spoolmanMode={spoolmanMode}
       />
 
+      <BulkEditSpoolsModal
+        isOpen={bulkEditOpen}
+        selectedCount={selectedIds.size}
+        isPending={bulkUpdateMutation.isPending}
+        availableLocations={storageLocations.map((l) => ({ id: l.id, name: l.name }))}
+        availableMaterials={dedupeAndSort((spools ?? []).map((s) => s.material))}
+        availableSubtypes={dedupeAndSort((spools ?? []).map((s) => s.subtype))}
+        availableBrands={dedupeAndSort((spools ?? []).map((s) => s.brand))}
+        availableCategories={dedupeAndSort((spools ?? []).map((s) => s.category))}
+        availableSlicerFilaments={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament))}
+        availableSlicerFilamentNames={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament_name))}
+        onClose={() => setBulkEditOpen(false)}
+        onApply={(patch) => bulkUpdateMutation.mutate({ ids: [...selectedIds], update: patch })}
+      />
+
+      {bulkConfirmAction && (
+        <ConfirmModal
+          title={
+            bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteTitle') :
+            bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveTitle') :
+            bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreTitle') :
+            t('inventory.bulk.resetUsageTitle')
+          }
+          message={
+            bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteMessage', { count: selectedIds.size }) :
+            bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveMessage', { count: selectedIds.size }) :
+            bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreMessage', { count: selectedIds.size }) :
+            t('inventory.bulk.resetUsageMessage', { count: selectedIds.size })
+          }
+          confirmText={
+            bulkConfirmAction === 'delete' ? t('common.delete') :
+            bulkConfirmAction === 'archive' ? t('inventory.archive') :
+            bulkConfirmAction === 'restore' ? t('inventory.restore') :
+            t('inventory.resetConsumedCounter')
+          }
+          variant={bulkConfirmAction === 'delete' ? 'danger' : 'warning'}
+          isLoading={
+            bulkDeleteMutation.isPending ||
+            bulkArchiveMutation.isPending ||
+            bulkRestoreMutation.isPending ||
+            bulkResetConsumedCounterMutation.isPending
+          }
+          onConfirm={() => {
+            const ids = [...selectedIds];
+            if (bulkConfirmAction === 'delete') bulkDeleteMutation.mutate(ids);
+            else if (bulkConfirmAction === 'archive') bulkArchiveMutation.mutate(ids);
+            else if (bulkConfirmAction === 'restore') bulkRestoreMutation.mutate(ids);
+            else bulkResetConsumedCounterMutation.mutate(ids);
+          }}
+          onCancel={() => setBulkConfirmAction(null)}
+        />
+      )}
+
       {csvImportOpen && (
         <SpoolCsvImportModal
           onClose={() => setCsvImportOpen(false)}
@@ -2223,12 +2515,15 @@ function SpoolCard({
 
 /* Single spool row for table view */
 function SpoolTableRow({
-  spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
+  spool, remaining, pct, isSelected, onToggleSelected,
+  onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spool: InventorySpool;
   remaining: number;
   pct: number;
+  isSelected?: boolean;
+  onToggleSelected?: () => void;
   onEdit: () => void;
   onCopy?: () => void;
   onRestore: () => void;
@@ -2248,9 +2543,20 @@ function SpoolTableRow({
     <tr
       className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
         spool.archived_at ? 'opacity-50' : ''
-      }`}
+      } ${isSelected ? 'bg-bambu-green/10' : ''}`}
       onClick={onEdit}
     >
+      <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
+        {onToggleSelected && (
+          <input
+            type="checkbox"
+            className="h-4 w-4 cursor-pointer"
+            aria-label={t('inventory.bulk.selectRow')}
+            checked={!!isSelected}
+            onChange={onToggleSelected}
+          />
+        )}
+      </td>
       {visibleColumns.map((colId) => (
         <td key={colId} className="py-3 px-4">
           {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
@@ -2303,6 +2609,7 @@ function SpoolTableGroup({
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
+  selectedIds, onToggleSelected, onToggleGroupSelected,
 }: {
   spools: InventorySpool[];
   // Aggregate of all members (summed quantities, shared identity) — rendered
@@ -2325,7 +2632,11 @@ function SpoolTableGroup({
   dateFormat: DateFormat;
   t: TFn;
   onSyncWeight?: (spool: InventorySpool) => void;
+  selectedIds?: Set<number>;
+  onToggleSelected?: (id: number) => void;
+  onToggleGroupSelected?: (ids: number[], select: boolean) => void;
 }) {
+  const allMembersSelected = !!selectedIds && spools.every((s) => selectedIds.has(s.id));
   return (
     <>
       {/* Group header row */}
@@ -2333,6 +2644,17 @@ function SpoolTableGroup({
         className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
         onClick={onToggle}
       >
+        <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
+          {onToggleGroupSelected && (
+            <input
+              type="checkbox"
+              className="h-4 w-4 cursor-pointer"
+              aria-label={t('inventory.bulk.selectGroup')}
+              checked={allMembersSelected}
+              onChange={(e) => onToggleGroupSelected(spools.map((s) => s.id), e.target.checked)}
+            />
+          )}
+        </td>
         {visibleColumns.map((colId, idx) => (
           <td key={colId} className="py-3 px-4">
             {idx === 0 ? (
@@ -2365,6 +2687,8 @@ function SpoolTableGroup({
             spool={spool}
             remaining={r}
             pct={p}
+            isSelected={selectedIds?.has(spool.id)}
+            onToggleSelected={onToggleSelected ? () => onToggleSelected(spool.id) : undefined}
             onEdit={() => onEdit(spool)}
             onCopy={onCopy ? () => onCopy(spool) : undefined}
             onRestore={() => {}}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-ClT8LTw_.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/index-DSFMlFH_.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-Dteqqsyc.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CHyRb--b.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DSFMlFH_.css">
+    <script type="module" crossorigin src="/assets/index-ClT8LTw_.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Dteqqsyc.css">
   </head>
   <body>
     <div id="root"></div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است