Browse Source

Easy management of printer locations (groups) (issue #2962)- #2970 (#2995)

WATARIE 1 day ago
parent
commit
fa25956738
38 changed files with 2379 additions and 5 deletions
  1. 1 0
      CHANGELOG.md
  2. 240 0
      backend/app/api/routes/printer_locations.py
  3. 29 0
      backend/app/core/database.py
  4. 2 0
      backend/app/main.py
  5. 2 0
      backend/app/models/__init__.py
  6. 30 0
      backend/app/models/printer_location.py
  7. 6 0
      backend/app/schemas/printer.py
  8. 116 0
      backend/app/schemas/printer_location.py
  9. 341 0
      backend/tests/integration/test_printer_locations_api.py
  10. 2 0
      frontend/src/App.tsx
  11. 187 0
      frontend/src/__tests__/pages/PrinterLocationsPage.test.tsx
  12. 32 0
      frontend/src/api/client.ts
  13. 8 0
      frontend/src/hooks/useWebSocket.ts
  14. 43 0
      frontend/src/i18n/locales/de.ts
  15. 45 0
      frontend/src/i18n/locales/en.ts
  16. 43 0
      frontend/src/i18n/locales/es.ts
  17. 43 0
      frontend/src/i18n/locales/fr.ts
  18. 43 0
      frontend/src/i18n/locales/it.ts
  19. 43 0
      frontend/src/i18n/locales/ja.ts
  20. 43 0
      frontend/src/i18n/locales/ko.ts
  21. 43 0
      frontend/src/i18n/locales/nl.ts
  22. 43 0
      frontend/src/i18n/locales/pt-BR.ts
  23. 43 0
      frontend/src/i18n/locales/ru.ts
  24. 43 0
      frontend/src/i18n/locales/sv.ts
  25. 43 0
      frontend/src/i18n/locales/tr.ts
  26. 43 0
      frontend/src/i18n/locales/uk.ts
  27. 43 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 43 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 723 0
      frontend/src/pages/PrinterLocationsPage.tsx
  30. 10 0
      frontend/src/pages/PrintersPage.tsx
  31. 0 0
      static/assets/ImagePreviewModal-BdV2HXT0.js
  32. 0 1
      static/assets/PdfPreviewModal-qR-OFMS4.js
  33. 0 0
      static/assets/SpreadsheetPreviewModal-1LJAWv55.js
  34. 0 1
      static/assets/index-CFtTE_hL.js
  35. 1 0
      static/assets/index-CaVfzyDI.css
  36. 0 1
      static/assets/index-MteGyJn2.css
  37. 0 0
      static/assets/pdf-DXlN_HIi.js
  38. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests.
 - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again.
 - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
 - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.

+ 240 - 0
backend/app/api/routes/printer_locations.py

@@ -0,0 +1,240 @@
+"""Printer locations (groups): list, create, rename, restyle, delete, assign (#2962).
+
+A printer's location is the free-text ``printers.location`` column, which the
+scheduler's model-based targeting (``print_queue.target_location``) and the
+printers filter match exactly. ``printer_locations`` adds the rest: a location
+with no printers yet, and its icon and colour. The list is the union of both,
+so a location typed into the printer dialog shows up here without a row.
+
+Every write is one transaction on the server. The page used to send one PATCH
+per printer from its cached printer list: a failure halfway left a location
+split under two names, and a printer another user had moved in the meantime
+was moved back.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.printer_location import PrinterLocation
+from backend.app.models.user import User
+from backend.app.schemas.printer_location import (
+    PrinterLocationAssign,
+    PrinterLocationAssignResult,
+    PrinterLocationCreate,
+    PrinterLocationDelete,
+    PrinterLocationDeleteResult,
+    PrinterLocationResponse,
+    PrinterLocationUpdate,
+)
+from backend.app.utils.natural_sort import natural_sort_key
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/printer-locations", tags=["printer-locations"])
+
+DUPLICATE_NAME = "A location with this name already exists"
+
+
+def _blank_location():
+    """Printers with no location: NULL, or "" from before blanks were folded."""
+    return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
+
+
+async def _printer_counts(db: AsyncSession) -> dict[str, int]:
+    result = await db.execute(
+        select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location)
+    )
+    return dict(result.all())
+
+
+async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
+    result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
+    return result.scalar_one_or_none()
+
+
+async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool:
+    """Whether ``name`` is a location already, ignoring case.
+
+    Matching elsewhere is exact, so "Workshop" and "workshop" would be two
+    locations that look like one. Refusing the second keeps them apart.
+    """
+    key = name.casefold()
+    rows = (await db.execute(select(PrinterLocation.name))).scalars().all()
+    used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all()
+    return any(n.casefold() == key and n != ignore for n in (*rows, *used))
+
+
+async def _conflicts(db: AsyncSession, name: str) -> bool:
+    """Whether using ``name`` would add a case variant of a location.
+
+    A name that already exists exactly is never a conflict, even when an older
+    install also holds a case variant of it: refusing would leave no way to
+    move a printer into, or style, a location that is plainly there.
+    """
+    if await _row(db, name) is not None:
+        return False
+    if name in await _printer_counts(db):
+        return False
+    return await _name_taken(db, name)
+
+
+async def _broadcast() -> None:
+    await ws_manager.broadcast({"type": "printer_locations_changed"})
+
+
+@router.get("/", response_model=list[PrinterLocationResponse])
+async def list_printer_locations(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """Every location: those with a row, and those only printers carry."""
+    counts = await _printer_counts(db)
+    rows = (await db.execute(select(PrinterLocation))).scalars().all()
+    out = {
+        row.name: PrinterLocationResponse(
+            id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+        )
+        for row in rows
+    }
+    for name, count in counts.items():
+        if name not in out:
+            out[name] = PrinterLocationResponse(name=name, printer_count=count)
+    return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name))
+
+
+@router.post("/", response_model=PrinterLocationResponse, status_code=201)
+async def create_printer_location(
+    data: PrinterLocationCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+):
+    """Create a location, with or without printers in it yet."""
+    if await _row(db, data.name) is not None or await _conflicts(db, data.name):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    # A location printers already use but that has no row yet gets one, which is
+    # how it gets an icon and colour.
+    row = PrinterLocation(name=data.name, icon=data.icon, color=data.color)
+    db.add(row)
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(row)
+    await _broadcast()
+    counts = await _printer_counts(db)
+    return PrinterLocationResponse(
+        id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+    )
+
+
+@router.patch("/", response_model=PrinterLocationResponse)
+async def update_printer_location(
+    data: PrinterLocationUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+):
+    """Rename a location and/or change its icon and colour.
+
+    A rename moves its printers and the queue items that target it in the same
+    transaction, so an "any printer in <location>" job, and the next run of a
+    batch, keep finding their printers.
+    """
+    row = await _row(db, data.name)
+    counts = await _printer_counts(db)
+    if row is None and data.name not in counts:
+        raise HTTPException(status_code=404, detail="Location not found")
+
+    name = data.name
+    new_name = data.new_name if "new_name" in data.model_fields_set else None
+    if new_name is not None and new_name != name:
+        if await _name_taken(db, new_name, ignore=name):
+            raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+        await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
+        # Every row, not only pending ones: a batch clones its next run from
+        # its newest row whatever that row's status, so a finished run still
+        # pointing at the old name would send future runs nowhere. A rename is
+        # the same place under a new name, so history follows it too.
+        await db.execute(
+            update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name)
+        )
+        name = new_name
+
+    if row is None:
+        row = PrinterLocation(name=name)
+        db.add(row)
+    else:
+        row.name = name
+    if "icon" in data.model_fields_set:
+        row.icon = data.icon
+    if "color" in data.model_fields_set:
+        row.color = data.color
+
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc
+    await db.refresh(row)
+    await _broadcast()
+    counts = await _printer_counts(db)
+    return PrinterLocationResponse(
+        id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
+    )
+
+
+@router.post("/delete", response_model=PrinterLocationDeleteResult)
+async def delete_printer_locations(
+    data: PrinterLocationDelete,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+):
+    """Delete locations; their printers end up with no location.
+
+    Pending queue items that target a deleted location are left alone: changing
+    them to "any location" would let them start on printers they were meant to
+    stay off.
+    """
+    names = set(data.names)
+    existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
+    existing |= set(await _printer_counts(db)) & names
+    await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
+    moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
+    await db.commit()
+    await _broadcast()
+    return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
+
+
+@router.post("/assign", response_model=PrinterLocationAssignResult)
+async def assign_printer_location(
+    data: PrinterLocationAssign,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+):
+    """Move printers into a location, or out of any with null.
+
+    By printer id, on the server: a printer someone else moved meanwhile is
+    moved again only if it is in this request.
+    """
+    # Moving into a new case variant of an existing location would split it.
+    if data.location is not None and await _conflicts(db, data.location):
+        raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+    ids = list(dict.fromkeys(data.printer_ids))
+    found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all()
+    missing = sorted(set(ids) - set(found))
+    if missing:
+        raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
+    result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
+    await db.commit()
+    await _broadcast()
+    return PrinterLocationAssignResult(moved=result.rowcount or 0)

+ 29 - 0
backend/app/core/database.py

@@ -349,6 +349,7 @@ async def init_db():
         print_queue,
         printer,
         printer_ha_sensor,
+        printer_location,
         printer_sensor_history,
         project,
         project_bom,
@@ -5184,6 +5185,9 @@ async def run_migrations(conn):
     # Spoolman and the location sync then imported as storage locations.
     await _migrate_drop_ams_slot_locations(conn)
 
+    # Data migration: printer locations as the locations API stores them (#2962).
+    await _migrate_normalize_printer_locations(conn)
+
     # Migration: link a batch to the external record that asked for it (a shop
     # order an integration turned into prints). The unique index is what makes
     # a retried create safe; both columns are new, so no row can violate it.
@@ -5531,6 +5535,31 @@ async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
     )
 
 
+async def _migrate_normalize_printer_locations(conn) -> None:
+    """Trim printer locations and store a blank one as NULL (#2962).
+
+    Nothing trimmed ``printers.location`` before, so "Workshop " and "Workshop"
+    could both be stored, and "" sat next to NULL for "no location". The
+    Printer Locations API trims every name it is given, so an untrimmed stored
+    value could never be matched to rename or delete it. Queue items'
+    ``target_location`` is trimmed the same way, so model-based jobs keep
+    matching their printers exactly. Idempotent: only rows that change are
+    touched.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        for table, column in (("printers", "location"), ("print_queue", "target_location")):
+            await conn.execute(
+                text(f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL AND TRIM({column}) = ''")  # noqa: S608  # nosec B608 — fixed identifiers
+            )
+            await conn.execute(
+                text(
+                    f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})"
+                )  # noqa: S608  # nosec B608 — fixed identifiers
+            )
+
+
 async def _migrate_drop_ams_slot_locations(conn) -> None:
     """Remove imported AMS slot markers from the storage-location catalogue.
 

+ 2 - 0
backend/app/main.py

@@ -61,6 +61,7 @@ from backend.app.api.routes import (
     pipeline_runs,
     print_log,
     print_queue,
+    printer_locations,
     printer_sensor_history,
     printers,
     projects,
@@ -10637,6 +10638,7 @@ app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
+app.include_router(printer_locations.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(finance.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -25,6 +25,7 @@ from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.printer import Printer
 from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.printer_location import PrinterLocation
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.scheduled_drying import ScheduledDrying
@@ -48,6 +49,7 @@ from backend.app.models.user_totp import UserTOTP
 
 __all__ = [
     "Printer",
+    "PrinterLocation",
     "PrintArchive",
     "Filament",
     "Settings",

+ 30 - 0
backend/app/models/printer_location.py

@@ -0,0 +1,30 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class PrinterLocation(Base):
+    """A printer location (group) and how it looks (#2962).
+
+    A printer's location is still the free-text ``printers.location`` column,
+    which the scheduler's model-based targeting and the printers filter match
+    exactly. This table only adds what that column cannot hold: a location with
+    no printers yet (a rack or room planned before the printers move in), and
+    the icon and colour the Printer Locations page shows for it. A location used
+    by printers but missing here is still a location, with no icon or colour.
+    """
+
+    __tablename__ = "printer_locations"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # Same width as printers.location, which holds the same value.
+    name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+    # An IconPicker name ("home", "wrench"); None shows the default icon.
+    icon: Mapped[str | None] = mapped_column(String(50))
+    # "#rrggbb"; None means no colour.
+    color: Mapped[str | None] = mapped_column(String(7))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 6 - 0
backend/app/schemas/printer.py

@@ -2,6 +2,7 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator, model_validator
 
+from backend.app.schemas.printer_location import normalize_location_name
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 
 
@@ -47,6 +48,10 @@ class PrinterCreate(PrinterBase):
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     access_code: str = Field(..., min_length=1, max_length=20)
 
+    # Input only, not on PrinterBase: SQLite never enforced the column width,
+    # so a stored location may be longer, and the response must still read it.
+    _location = field_validator("location")(normalize_location_name)
+
 
 class PlateDetectionROI(BaseModel):
     """Region of interest for plate detection (percentages 0.0-1.0)."""
@@ -67,6 +72,7 @@ class PrinterUpdate(BaseModel):
     access_code: str | None = None
     model: str | None = None
     location: str | None = None
+    _location = field_validator("location")(normalize_location_name)
     is_active: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | None = None

+ 116 - 0
backend/app/schemas/printer_location.py

@@ -0,0 +1,116 @@
+"""Printer locations (groups) and their appearance (#2962)."""
+
+from pydantic import BaseModel, Field, field_validator
+
+# printers.location, print_queue.target_location and printer_locations.name are
+# all VARCHAR(100). PostgreSQL refuses a longer value with an error mid-write,
+# so it is refused here instead.
+LOCATION_NAME_MAX_LENGTH = 100
+
+_ICON_PATTERN = r"^[a-z0-9-]{1,50}$"
+_COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$"
+
+
+def normalize_location_name(value: str | None) -> str | None:
+    """A location as stored: trimmed, and None for blank (no location).
+
+    "" and None both meant "no location" before this existed; folding them into
+    one value keeps the exact-match filters from treating them as two groups.
+    """
+    if value is None:
+        return None
+    if not isinstance(value, str):
+        raise ValueError("location must be a string")
+    value = value.strip()
+    if not value:
+        return None
+    if len(value) > LOCATION_NAME_MAX_LENGTH:
+        raise ValueError(f"location must be at most {LOCATION_NAME_MAX_LENGTH} characters")
+    return value
+
+
+def _required_name(value: str) -> str:
+    name = normalize_location_name(value)
+    if name is None:
+        raise ValueError("location name is required")
+    return name
+
+
+def _existing_name(value: str) -> str:
+    """The name of a location that already exists, as stored.
+
+    Not length-checked: SQLite never enforced the column width, so a stored
+    location can be longer than a new one may be, and it must still be possible
+    to rename or delete it.
+    """
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError("location name is required")
+    return value.strip()
+
+
+def _blank_to_none(value: str | None) -> str | None:
+    if isinstance(value, str) and not value.strip():
+        return None
+    return value
+
+
+class PrinterLocationResponse(BaseModel):
+    # None for a location that only exists on printers and has no row yet.
+    id: int | None = None
+    name: str
+    icon: str | None = None
+    color: str | None = None
+    printer_count: int = 0
+
+
+class PrinterLocationCreate(BaseModel):
+    name: str
+    icon: str | None = Field(default=None, pattern=_ICON_PATTERN)
+    color: str | None = Field(default=None, pattern=_COLOR_PATTERN)
+
+    _name = field_validator("name")(_required_name)
+    _blank = field_validator("icon", "color", mode="before")(_blank_to_none)
+
+
+class PrinterLocationUpdate(BaseModel):
+    """Rename and/or restyle the location called ``name``.
+
+    ``icon`` and ``color`` are only changed when sent; sending null or "" clears
+    them.
+    """
+
+    name: str
+    new_name: str | None = None
+    icon: str | None = Field(default=None, pattern=_ICON_PATTERN)
+    color: str | None = Field(default=None, pattern=_COLOR_PATTERN)
+
+    _name = field_validator("name")(_existing_name)
+    _new_name = field_validator("new_name")(_required_name)
+    _blank = field_validator("icon", "color", mode="before")(_blank_to_none)
+
+
+class PrinterLocationDelete(BaseModel):
+    names: list[str] = Field(min_length=1, max_length=500)
+
+    @field_validator("names")
+    @classmethod
+    def _names(cls, values: list[str]) -> list[str]:
+        return list(dict.fromkeys(_existing_name(v) for v in values))
+
+
+class PrinterLocationDeleteResult(BaseModel):
+    deleted: int
+    printers_ungrouped: int
+
+
+class PrinterLocationAssign(BaseModel):
+    """Move printers into ``location``, or out of any location with null."""
+
+    printer_ids: list[int] = Field(min_length=1, max_length=1000)
+    location: str | None = None
+
+    _location = field_validator("location")(normalize_location_name)
+
+
+class PrinterLocationAssignResult(BaseModel):
+    moved: int

+ 341 - 0
backend/tests/integration/test_printer_locations_api.py

@@ -0,0 +1,341 @@
+"""Printer locations (groups) API (#2962).
+
+Locations live on printers (``printers.location``) and, for appearance and
+empty locations, in ``printer_locations``. Every write is one server-side
+transaction: the page used to send one PATCH per printer from its cached list,
+so a failure halfway split a location, and another user's move was undone.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import inspect, select
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.printer_location import PrinterLocation
+
+BASE = "/api/v1/printer-locations/"
+
+
+async def _locations(client: AsyncClient) -> dict[str, dict]:
+    response = await client.get(BASE)
+    assert response.status_code == 200
+    return {loc["name"]: loc for loc in response.json()}
+
+
+async def _printer_location(db_session, printer) -> str | None:
+    # A column select reads the row, not the identity map, so nothing needs
+    # expiring; the id comes from the identity, which never lazy-loads.
+    printer_id = inspect(printer).identity[0]
+    return (await db_session.execute(select(Printer.location).where(Printer.id == printer_id))).scalar_one()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestList:
+    async def test_union_of_rows_and_printer_locations(self, async_client, printer_factory, db_session):
+        await printer_factory(name="A", location="Workshop")
+        await printer_factory(name="B", location="Workshop")
+        await printer_factory(name="C", location="")
+        await printer_factory(name="D", location=None)
+        db_session.add(PrinterLocation(name="Future rack", icon="home", color="#3b82f6"))
+        await db_session.commit()
+
+        locations = await _locations(async_client)
+
+        assert set(locations) == {"Workshop", "Future rack"}
+        assert locations["Workshop"]["printer_count"] == 2
+        assert locations["Workshop"]["id"] is None
+        assert locations["Future rack"] == {
+            "id": locations["Future rack"]["id"],
+            "name": "Future rack",
+            "icon": "home",
+            "color": "#3b82f6",
+            "printer_count": 0,
+        }
+
+    async def test_natural_order(self, async_client, db_session):
+        for name in ("Rack 10", "Rack 2", "Office"):
+            db_session.add(PrinterLocation(name=name))
+        await db_session.commit()
+
+        response = await async_client.get(BASE)
+
+        assert [loc["name"] for loc in response.json()] == ["Office", "Rack 2", "Rack 10"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestCreate:
+    async def test_an_empty_location_persists(self, async_client):
+        response = await async_client.post(BASE, json={"name": "  Basement  ", "icon": "home", "color": "#ef4444"})
+
+        assert response.status_code == 201
+        assert response.json()["name"] == "Basement"
+        assert "Basement" in await _locations(async_client)
+
+    async def test_a_location_printers_already_use_gets_a_row(self, async_client, printer_factory):
+        await printer_factory(location="Workshop")
+
+        response = await async_client.post(BASE, json={"name": "Workshop", "color": "#22c55e"})
+
+        assert response.status_code == 201
+        assert response.json()["printer_count"] == 1
+        assert response.json()["color"] == "#22c55e"
+
+    @pytest.mark.parametrize("name", ["Basement", "basement", "BASEMENT"])
+    async def test_a_duplicate_is_refused_ignoring_case(self, async_client, name):
+        await async_client.post(BASE, json={"name": "Basement"})
+
+        response = await async_client.post(BASE, json={"name": name})
+
+        assert response.status_code == 409
+
+    @pytest.mark.parametrize(
+        "body",
+        [
+            {"name": ""},
+            {"name": "   "},
+            {"name": "x" * 101},
+            {"name": "Ok", "color": "red"},
+            {"name": "Ok", "icon": "<script>"},
+        ],
+    )
+    async def test_invalid_input_is_a_422_not_a_500(self, async_client, body):
+        response = await async_client.post(BASE, json=body)
+
+        assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestUpdate:
+    async def test_rename_moves_printers_and_queue_items_together(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        inside = await printer_factory(location="Workshop")
+        outside = await printer_factory(location="Office")
+        archive = await archive_factory(inside.id)
+        pending = PrintQueueItem(
+            archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending"
+        )
+        done = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="completed")
+        db_session.add_all([pending, done])
+        await db_session.commit()
+
+        response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "Garage"})
+
+        assert response.status_code == 200
+        assert response.json()["printer_count"] == 1
+        assert await _printer_location(db_session, inside) == "Garage"
+        assert await _printer_location(db_session, outside) == "Office"
+        await db_session.refresh(pending)
+        await db_session.refresh(done)
+        assert pending.target_location == "Garage"
+        # Finished rows too: a batch clones its next run from its newest row,
+        # whatever its status.
+        assert done.target_location == "Garage"
+        assert set(await _locations(async_client)) == {"Garage", "Office"}
+
+    async def test_rename_onto_another_location_is_refused(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="Workshop")
+        await printer_factory(location="Office")
+
+        response = await async_client.patch(BASE, json={"name": "Workshop", "new_name": "office"})
+
+        assert response.status_code == 409
+        assert await _printer_location(db_session, a) == "Workshop"
+
+    async def test_a_case_only_rename_is_allowed(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="workshop")
+
+        response = await async_client.patch(BASE, json={"name": "workshop", "new_name": "Workshop"})
+
+        assert response.status_code == 200
+        assert await _printer_location(db_session, a) == "Workshop"
+
+    async def test_restyle_only_sent_fields(self, async_client):
+        await async_client.post(BASE, json={"name": "Shop", "icon": "home", "color": "#ef4444"})
+
+        await async_client.patch(BASE, json={"name": "Shop", "color": "#22c55e"})
+        after_color = (await _locations(async_client))["Shop"]
+        await async_client.patch(BASE, json={"name": "Shop", "icon": ""})
+        after_clear = (await _locations(async_client))["Shop"]
+
+        assert (after_color["icon"], after_color["color"]) == ("home", "#22c55e")
+        assert (after_clear["icon"], after_clear["color"]) == (None, "#22c55e")
+
+    async def test_unknown_location_is_a_404(self, async_client):
+        response = await async_client.patch(BASE, json={"name": "Nowhere", "color": "#22c55e"})
+
+        assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestDelete:
+    async def test_printers_end_up_ungrouped_and_rows_go(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="Workshop")
+        b = await printer_factory(location="Office")
+        await async_client.post(BASE, json={"name": "Empty"})
+
+        response = await async_client.post(f"{BASE}delete", json={"names": ["Workshop", "Empty", "Nowhere"]})
+
+        assert response.status_code == 200
+        assert response.json() == {"deleted": 2, "printers_ungrouped": 1}
+        assert await _printer_location(db_session, a) is None
+        assert await _printer_location(db_session, b) == "Office"
+        assert set(await _locations(async_client)) == {"Office"}
+
+    async def test_pending_jobs_keep_their_target(self, async_client, printer_factory, archive_factory, db_session):
+        """Turning them into "any location" would start them where they were
+        meant not to run."""
+        printer = await printer_factory(location="Workshop")
+        archive = await archive_factory(printer.id)
+        item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location="Workshop", status="pending")
+        db_session.add(item)
+        await db_session.commit()
+
+        await async_client.post(f"{BASE}delete", json={"names": ["Workshop"]})
+
+        await db_session.refresh(item)
+        assert item.target_location == "Workshop"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestAssign:
+    async def test_moves_only_the_listed_printers(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="Workshop")
+        b = await printer_factory(location="Workshop")
+        c = await printer_factory(location="Office")
+
+        response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, c.id], "location": "Garage"})
+
+        assert response.status_code == 200
+        assert response.json() == {"moved": 2}
+        assert await _printer_location(db_session, a) == "Garage"
+        assert await _printer_location(db_session, b) == "Workshop"
+        assert await _printer_location(db_session, c) == "Garage"
+
+    async def test_null_and_blank_ungroup(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="Workshop")
+        b = await printer_factory(location="Workshop")
+
+        await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id], "location": None})
+        await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": "  "})
+
+        assert await _printer_location(db_session, a) is None
+        assert await _printer_location(db_session, b) is None
+
+    async def test_an_unknown_printer_moves_nothing(self, async_client, printer_factory, db_session):
+        """All or nothing, so a bad id cannot leave half the selection moved."""
+        a = await printer_factory(location="Workshop")
+
+        response = await async_client.post(f"{BASE}assign", json={"printer_ids": [a.id, 99999], "location": "Garage"})
+
+        assert response.status_code == 404
+        assert await _printer_location(db_session, a) == "Workshop"
+
+    async def test_a_case_variant_of_a_location_is_refused(self, async_client, printer_factory, db_session):
+        a = await printer_factory(location="Workshop")
+        b = await printer_factory(location=None)
+
+        response = await async_client.post(f"{BASE}assign", json={"printer_ids": [b.id], "location": "workshop"})
+
+        assert response.status_code == 409
+        assert await _printer_location(db_session, a) == "Workshop"
+        assert await _printer_location(db_session, b) is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestPrinterLocationInput:
+    """The printer dialog writes the same column, with the same rules."""
+
+    async def test_too_long_is_a_422(self, async_client, printer_factory):
+        printer = await printer_factory()
+
+        response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": "x" * 101})
+
+        assert response.status_code == 422
+
+    async def test_blank_is_stored_as_no_location(self, async_client, printer_factory, db_session):
+        printer = await printer_factory(location="Workshop")
+
+        response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"location": "  "})
+
+        assert response.status_code == 200
+        assert await _printer_location(db_session, printer) is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestExistingData:
+    """Locations stored before the page existed: untrimmed, blank, case
+    variants, and on SQLite longer than the column width."""
+
+    async def test_migration_trims_and_blanks(self, test_engine, printer_factory, archive_factory, db_session):
+        from backend.app.core.database import _migrate_normalize_printer_locations
+
+        a = await printer_factory(location="Workshop ")
+        b = await printer_factory(location="   ")
+        c = await printer_factory(location="Office")
+        archive = await archive_factory(a.id)
+        item = PrintQueueItem(archive_id=archive.id, target_model="X1C", target_location=" Workshop", status="pending")
+        db_session.add(item)
+        await db_session.commit()
+
+        async with test_engine.begin() as conn:
+            await _migrate_normalize_printer_locations(conn)
+            # Idempotent: a second run changes nothing.
+            await _migrate_normalize_printer_locations(conn)
+
+        assert await _printer_location(db_session, a) == "Workshop"
+        assert await _printer_location(db_session, b) is None
+        assert await _printer_location(db_session, c) == "Office"
+        await db_session.refresh(item)
+        assert item.target_location == "Workshop"
+
+    async def test_case_variants_from_before_can_still_be_used(self, async_client, printer_factory, db_session):
+        """Refusing would leave no way to move a printer into a location that is
+        plainly there, or to style it."""
+        await printer_factory(location="Workshop")
+        await printer_factory(location="workshop")
+        newcomer = await printer_factory(location=None)
+
+        moved = await async_client.post(f"{BASE}assign", json={"printer_ids": [newcomer.id], "location": "Workshop"})
+        styled = await async_client.post(BASE, json={"name": "workshop", "color": "#22c55e"})
+        third = await async_client.post(BASE, json={"name": "WORKSHOP"})
+
+        assert moved.status_code == 200
+        assert await _printer_location(db_session, newcomer) == "Workshop"
+        assert styled.status_code == 201
+        assert third.status_code == 409
+
+    async def test_an_overlong_stored_name_can_be_renamed_and_deleted(self, async_client, printer_factory, db_session):
+        """SQLite never enforced VARCHAR(100)."""
+        long_name = "L" * 150
+        a = await printer_factory(location=long_name)
+        b = await printer_factory(location=long_name + "2")
+
+        renamed = await async_client.patch(BASE, json={"name": long_name, "new_name": "Short"})
+        deleted = await async_client.post(f"{BASE}delete", json={"names": [long_name + "2"]})
+
+        assert renamed.status_code == 200
+        assert await _printer_location(db_session, a) == "Short"
+        assert deleted.json() == {"deleted": 1, "printers_ungrouped": 1}
+        assert await _printer_location(db_session, b) is None
+
+
+class TestPrinterCreateLocation:
+    def test_create_trims_blanks_and_limits(self):
+        from pydantic import ValidationError
+
+        from backend.app.schemas.printer import PrinterCreate
+
+        base = {"name": "P", "serial_number": "00M09A000000001", "ip_address": "10.0.0.1", "access_code": "1234"}
+        assert PrinterCreate(**base, location="  Shop ").location == "Shop"
+        assert PrinterCreate(**base, location=" ").location is None
+        with pytest.raises(ValidationError):
+            PrinterCreate(**base, location="x" * 101)

+ 2 - 0
frontend/src/App.tsx

@@ -19,6 +19,7 @@ import { CamWallPage } from './pages/CamWallPage';
 import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { GroupEditPage } from './pages/GroupEditPage';
+import { PrinterLocationsPage } from './pages/PrinterLocationsPage';
 import InventoryPage from './pages/InventoryPage';
 import { MakerworldPage } from './pages/MakerworldPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -228,6 +229,7 @@ function App() {
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
+                  <Route path="printer-locations" element={<PermissionRoute permission="printers:read"><PrinterLocationsPage /></PermissionRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />

+ 187 - 0
frontend/src/__tests__/pages/PrinterLocationsPage.test.tsx

@@ -0,0 +1,187 @@
+/**
+ * Printer Locations page (#2962).
+ *
+ * Locations, with their icon and colour, come from the server: a location with
+ * no printers used to live only in one browser's localStorage, invisible to
+ * other users and devices. Every change is one request, so a failure cannot
+ * leave a location split across printers, and English counts never render as
+ * "5 printer".
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import i18n from '../../i18n';
+
+const permissions = { granted: ['printers:read', 'printers:update'] as string[] };
+
+const mockUseAuth = {
+  user: { id: 1, username: 'operator', permissions: [] as string[] },
+  authEnabled: true,
+  requiresSetup: false,
+  loading: false,
+  isAdmin: false,
+  login: vi.fn(),
+  loginWithToken: vi.fn(),
+  logout: vi.fn(),
+  refreshUser: vi.fn(),
+  refreshAuth: vi.fn(),
+  hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
+  hasAnyPermission: vi.fn(() => true),
+  hasAllPermissions: vi.fn(() => true),
+  canModify: vi.fn(() => true),
+};
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => mockUseAuth };
+});
+
+import { PrinterLocationsPage } from '../../pages/PrinterLocationsPage';
+
+const printer = (id: number, name: string, location: string | null) => ({
+  id,
+  name,
+  location,
+  model: 'X1C',
+  serial_number: `S${id}`,
+  ip_address: `10.0.0.${id}`,
+  is_active: true,
+});
+
+const PRINTERS = [
+  printer(1, 'Alpha', 'Workshop'),
+  printer(2, 'Bravo', 'Workshop'),
+  printer(3, 'Charlie', null),
+  printer(4, 'Delta', ''),
+];
+
+const LOCATIONS = [
+  { id: 1, name: 'Future rack', icon: 'home', color: '#3b82f6', printer_count: 0 },
+  { id: null, name: 'Workshop', icon: null, color: null, printer_count: 5 },
+];
+
+let requests: { method: string; path: string; body: unknown }[];
+
+beforeEach(() => {
+  permissions.granted = ['printers:read', 'printers:update'];
+  requests = [];
+  const record = async (request: Request) => {
+    requests.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() });
+  };
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE' })),
+    http.get('/api/v1/printer-locations/', () => HttpResponse.json(LOCATIONS)),
+    http.post('/api/v1/printer-locations/', async ({ request }) => {
+      await record(request);
+      return HttpResponse.json({ id: 9, name: 'Basement', icon: null, color: null, printer_count: 0 }, { status: 201 });
+    }),
+    http.post('/api/v1/printer-locations/assign', async ({ request }) => {
+      await record(request);
+      return HttpResponse.json({ moved: 2 });
+    }),
+    http.post('/api/v1/printer-locations/delete', async ({ request }) => {
+      await record(request);
+      return HttpResponse.json({ deleted: 2, printers_ungrouped: 5 });
+    }),
+  );
+});
+
+describe('PrinterLocationsPage', () => {
+  it('shows server locations, including an empty one, with readable English counts', async () => {
+    render(<PrinterLocationsPage />);
+
+    expect(await screen.findByText('Future rack')).toBeInTheDocument();
+    expect(screen.getByText('Workshop')).toBeInTheDocument();
+    expect(screen.getByText('Printers: 0')).toBeInTheDocument();
+    expect(screen.getByText('Printers: 5')).toBeInTheDocument();
+    // NULL and "" are both "no location".
+    expect(screen.getByText('Printers without a location: 2')).toBeInTheDocument();
+    expect(screen.getByText('Charlie')).toBeInTheDocument();
+    expect(screen.getByText('Delta')).toBeInTheDocument();
+  });
+
+  it('creates a location on the server, with a name capped at the column width', async () => {
+    const user = userEvent.setup();
+    render(<PrinterLocationsPage />);
+
+    await user.click(await screen.findByRole('button', { name: /new location/i }));
+    const input = screen.getByRole('textbox', { name: /location name/i });
+    expect(input).toHaveAttribute('maxLength', '100');
+    await user.type(input, 'Basement');
+    await user.click(screen.getByRole('button', { name: '#ef4444' }));
+    const dialog = screen.getByRole('heading', { name: 'Create Location' }).closest('div')!;
+    await user.click(within(dialog).getByRole('button', { name: /new location/i }));
+
+    await waitFor(() => expect(requests).toHaveLength(1));
+    expect(requests[0]).toEqual({
+      method: 'POST',
+      path: '/api/v1/printer-locations/',
+      body: { name: 'Basement', icon: null, color: '#ef4444' },
+    });
+  });
+
+  it('moves selected printers in one request', async () => {
+    const user = userEvent.setup();
+    render(<PrinterLocationsPage />);
+
+    await user.click(await screen.findByRole('button', { name: /select all/i }));
+    expect(screen.getByText('Selected: 2')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /^move$/i }));
+    const dialog = screen.getByRole('heading', { name: 'Move printers (2)' }).closest('div')!;
+    await user.selectOptions(within(dialog).getByRole('combobox'), 'Future rack');
+    await user.click(within(dialog).getByRole('button', { name: /^move$/i }));
+
+    await waitFor(() => expect(requests).toHaveLength(1));
+    expect(requests[0]).toEqual({
+      method: 'POST',
+      path: '/api/v1/printer-locations/assign',
+      body: { printer_ids: [3, 4], location: 'Future rack' },
+    });
+    expect(await screen.findByText('Printers moved: 2')).toBeInTheDocument();
+  });
+
+  it('deletes selected locations in one request and names what it affects', async () => {
+    const user = userEvent.setup();
+    render(<PrinterLocationsPage />);
+
+    await user.click(await screen.findByRole('button', { name: /^select$/i }));
+    await user.click(screen.getByText('Future rack'));
+    await user.click(screen.getByText('Workshop'));
+    await user.click(screen.getByRole('button', { name: /delete selected/i }));
+
+    expect(screen.getByText('Delete locations (2)')).toBeInTheDocument();
+    expect(
+      screen.getByText('Delete Future rack, Workshop? Printers left without a location: 5.'),
+    ).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+    await waitFor(() => expect(requests).toHaveLength(1));
+    expect(requests[0]).toEqual({
+      method: 'POST',
+      path: '/api/v1/printer-locations/delete',
+      body: { names: ['Future rack', 'Workshop'] },
+    });
+  });
+
+  it('is read-only without printers:update', async () => {
+    permissions.granted = ['printers:read'];
+    render(<PrinterLocationsPage />);
+
+    expect(await screen.findByText('Workshop')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /new location/i })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /^select$/i })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /select all/i })).not.toBeInTheDocument();
+  });
+});
+
+describe('printer locations strings', () => {
+  it('keeps printers.dropToQueue, which the printer card still uses', () => {
+    expect(i18n.t('printers.dropToQueue', { lng: 'en' })).toBe('Drop to queue');
+    expect(i18n.t('printers.dropToQueue', { lng: 'ru' })).not.toBe('printers.dropToQueue');
+  });
+});

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

@@ -413,6 +413,23 @@ export interface OverlayStatus {
 }
 
 // Printer types
+// Printer locations (groups) and their appearance (#2962). `id` is null for a
+// location only printers carry, with no row of its own yet.
+export interface PrinterLocation {
+  id: number | null;
+  name: string;
+  icon: string | null;
+  color: string | null;
+  printer_count: number;
+}
+
+export interface PrinterLocationUpdate {
+  name: string;
+  new_name?: string;
+  icon?: string | null;
+  color?: string | null;
+}
+
 export interface Printer {
   id: number;
   name: string;
@@ -4906,6 +4923,21 @@ export const api = {
     if (location) params.set('location', location);
     return request<Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
   },
+  getPrinterLocations: () => request<PrinterLocation[]>('/printer-locations/'),
+  createPrinterLocation: (data: { name: string; icon?: string | null; color?: string | null }) =>
+    request<PrinterLocation>('/printer-locations/', { method: 'POST', body: JSON.stringify(data) }),
+  updatePrinterLocation: (data: PrinterLocationUpdate) =>
+    request<PrinterLocation>('/printer-locations/', { method: 'PATCH', body: JSON.stringify(data) }),
+  deletePrinterLocations: (names: string[]) =>
+    request<{ deleted: number; printers_ungrouped: number }>('/printer-locations/delete', {
+      method: 'POST',
+      body: JSON.stringify({ names }),
+    }),
+  assignPrinterLocation: (printerIds: number[], location: string | null) =>
+    request<{ moved: number }>('/printer-locations/assign', {
+      method: 'POST',
+      body: JSON.stringify({ printer_ids: printerIds, location }),
+    }),
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
   refreshPrinterStatus: (id: number) =>

+ 8 - 0
frontend/src/hooks/useWebSocket.ts

@@ -409,6 +409,14 @@ export function useWebSocket() {
         debouncedInvalidate('announcements');
         break;
 
+      case 'printer_locations_changed':
+        // A location was created, renamed, restyled or deleted, or printers moved
+        // between them (#2962). Every page that shows a printer's location reads
+        // it from the printer list.
+        debouncedInvalidate('printer-locations');
+        debouncedInvalidate('printers');
+        break;
+
       case 'inventory_changed':
         // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
         debouncedInvalidate('inventory-spools');

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Druckerstandorte',
+      subtitle: '{{grouped}} mit Standort, {{ungrouped}} ohne',
+      search: 'Standorte durchsuchen...',
+      showEmpty: 'Leere anzeigen',
+      hideEmpty: 'Leere ausblenden',
+      select: 'Auswählen',
+      done: 'Fertig',
+      sortNameAsc: 'Nach Name A→Z',
+      sortNameDesc: 'Nach Name Z→A',
+      sortCountAsc: 'Wenigste Drucker',
+      sortCountDesc: 'Meiste Drucker',
+      create: 'Neuer Standort',
+      createTitle: 'Standort anlegen',
+      editTitle: 'Standort bearbeiten',
+      namePlaceholder: 'Name des Standorts',
+      icon: 'Symbol',
+      color: 'Farbe',
+      noColor: 'Keine Farbe',
+      none: 'Noch keine Druckerstandorte',
+      noResults: 'Kein Standort passt zur Suche',
+      printerCount: 'Drucker: {{count}}',
+      noPrinters: 'Keine Drucker an diesem Standort',
+      selectPrinter: 'Drucker auswählen',
+      move: 'An einen anderen Standort verschieben',
+      moveButton: 'Verschieben',
+      removeFromLocation: 'Vom Standort entfernen',
+      ungroupedPrinters: 'Drucker ohne Standort: {{count}}',
+      selected: 'Ausgewählt: {{count}}',
+      deleteSelected: 'Auswahl löschen',
+      moveTitle: 'Drucker verschieben',
+      moveTitleMany: 'Drucker verschieben ({{count}})',
+      targetPlaceholder: 'Standort auswählen...',
+      noLocation: 'Kein Standort',
+      deleteTitle: 'Standort löschen',
+      deleteTitleMany: 'Standorte löschen ({{count}})',
+      deleteMessage: '{{names}} löschen? Drucker danach ohne Standort: {{count}}.',
+      created: 'Standort angelegt',
+      saved: 'Standort aktualisiert',
+      deleted: 'Standorte gelöscht: {{count}}',
+      moved: 'Drucker verschoben: {{count}}',
+      failed: 'Die Änderung konnte nicht gespeichert werden: {{error}}',
+    },
     addPreflight: {
       checking: 'Verbindung wird geprüft...',
       warning: 'Einige Verbindungsprüfungen sind fehlgeschlagen. Dieser Drucker wird möglicherweise als offline angezeigt. Prüfe die Punkte unten, behebe was möglich ist, oder speichere trotzdem.',

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

@@ -237,6 +237,51 @@ export default {
 
   // Printers page
   printers: {
+    // Printer Locations page (#2962). Counts are written as "label: N" so
+    // they read correctly in every language without plural forms.
+    locations: {
+      title: 'Printer Locations',
+      subtitle: '{{grouped}} in a location, {{ungrouped}} without one',
+      search: 'Search locations...',
+      showEmpty: 'Show empty',
+      hideEmpty: 'Hide empty',
+      select: 'Select',
+      done: 'Done',
+      sortNameAsc: 'Name A→Z',
+      sortNameDesc: 'Name Z→A',
+      sortCountAsc: 'Fewest printers',
+      sortCountDesc: 'Most printers',
+      create: 'New Location',
+      createTitle: 'Create Location',
+      editTitle: 'Edit Location',
+      namePlaceholder: 'Location name',
+      icon: 'Icon',
+      color: 'Color',
+      noColor: 'No color',
+      none: 'No printer locations yet',
+      noResults: 'No locations match your search',
+      printerCount: 'Printers: {{count}}',
+      noPrinters: 'No printers in this location',
+      selectPrinter: 'Select printer',
+      move: 'Move to another location',
+      moveButton: 'Move',
+      removeFromLocation: 'Remove from location',
+      ungroupedPrinters: 'Printers without a location: {{count}}',
+      selected: 'Selected: {{count}}',
+      deleteSelected: 'Delete selected',
+      moveTitle: 'Move printer',
+      moveTitleMany: 'Move printers ({{count}})',
+      targetPlaceholder: 'Select a location...',
+      noLocation: 'No location',
+      deleteTitle: 'Delete location',
+      deleteTitleMany: 'Delete locations ({{count}})',
+      deleteMessage: 'Delete {{names}}? Printers left without a location: {{count}}.',
+      created: 'Location created',
+      saved: 'Location updated',
+      deleted: 'Locations deleted: {{count}}',
+      moved: 'Printers moved: {{count}}',
+      failed: 'Could not save the change: {{error}}',
+    },
     title: 'Printers',
     addPrinter: 'Add Printer',
     addPreflight: {

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Ubicaciones de impresoras',
+      subtitle: '{{grouped}} con ubicación, {{ungrouped}} sin ubicación',
+      search: 'Buscar ubicaciones...',
+      showEmpty: 'Mostrar vacías',
+      hideEmpty: 'Ocultar vacías',
+      select: 'Seleccionar',
+      done: 'Listo',
+      sortNameAsc: 'Nombre A→Z',
+      sortNameDesc: 'Nombre Z→A',
+      sortCountAsc: 'Menos impresoras',
+      sortCountDesc: 'Más impresoras',
+      create: 'Nueva ubicación',
+      createTitle: 'Crear ubicación',
+      editTitle: 'Editar ubicación',
+      namePlaceholder: 'Nombre de la ubicación',
+      icon: 'Icono',
+      color: 'Color de la ubicación',
+      noColor: 'Sin color',
+      none: 'Aún no hay ubicaciones de impresoras',
+      noResults: 'Ninguna ubicación coincide con la búsqueda',
+      printerCount: 'Impresoras: {{count}}',
+      noPrinters: 'No hay impresoras en esta ubicación',
+      selectPrinter: 'Seleccionar impresora',
+      move: 'Mover a otra ubicación',
+      moveButton: 'Mover',
+      removeFromLocation: 'Quitar de la ubicación',
+      ungroupedPrinters: 'Impresoras sin ubicación: {{count}}',
+      selected: 'Seleccionadas: {{count}}',
+      deleteSelected: 'Eliminar selección',
+      moveTitle: 'Mover impresora',
+      moveTitleMany: 'Mover impresoras ({{count}})',
+      targetPlaceholder: 'Selecciona una ubicación...',
+      noLocation: 'Sin ubicación',
+      deleteTitle: 'Eliminar ubicación',
+      deleteTitleMany: 'Eliminar ubicaciones ({{count}})',
+      deleteMessage: '¿Eliminar {{names}}? Impresoras que quedarán sin ubicación: {{count}}.',
+      created: 'Ubicación creada',
+      saved: 'Ubicación actualizada',
+      deleted: 'Ubicaciones eliminadas: {{count}}',
+      moved: 'Impresoras movidas: {{count}}',
+      failed: 'No se pudo guardar el cambio: {{error}}',
+    },
     addPreflight: {
       checking: 'Comprobando la conexión...',
       warning: 'Algunas comprobaciones de conexión fallaron. Esta impresora podría aparecer como desconectada. Revisa las comprobaciones de abajo, soluciona lo que puedas o guárdala de todos modos.',

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Emplacements des imprimantes',
+      subtitle: '{{grouped}} avec emplacement, {{ungrouped}} sans',
+      search: 'Rechercher des emplacements...',
+      showEmpty: 'Afficher les vides',
+      hideEmpty: 'Masquer les vides',
+      select: 'Sélectionner',
+      done: 'Terminé',
+      sortNameAsc: 'Nom A→Z',
+      sortNameDesc: 'Nom Z→A',
+      sortCountAsc: 'Moins d\'imprimantes',
+      sortCountDesc: 'Plus d\'imprimantes',
+      create: 'Nouvel emplacement',
+      createTitle: 'Créer un emplacement',
+      editTitle: 'Modifier l\'emplacement',
+      namePlaceholder: 'Nom de l\'emplacement',
+      icon: 'Icône',
+      color: 'Couleur',
+      noColor: 'Aucune couleur',
+      none: 'Aucun emplacement d\'imprimante pour l\'instant',
+      noResults: 'Aucun emplacement ne correspond à la recherche',
+      printerCount: 'Imprimantes : {{count}}',
+      noPrinters: 'Aucune imprimante à cet emplacement',
+      selectPrinter: 'Sélectionner l\'imprimante',
+      move: 'Déplacer vers un autre emplacement',
+      moveButton: 'Déplacer',
+      removeFromLocation: 'Retirer de l\'emplacement',
+      ungroupedPrinters: 'Imprimantes sans emplacement : {{count}}',
+      selected: 'Sélection : {{count}}',
+      deleteSelected: 'Supprimer la sélection',
+      moveTitle: 'Déplacer l\'imprimante',
+      moveTitleMany: 'Déplacer les imprimantes ({{count}})',
+      targetPlaceholder: 'Choisir un emplacement...',
+      noLocation: 'Aucun emplacement',
+      deleteTitle: 'Supprimer l\'emplacement',
+      deleteTitleMany: 'Supprimer les emplacements ({{count}})',
+      deleteMessage: 'Supprimer {{names}} ? Imprimantes qui resteront sans emplacement : {{count}}.',
+      created: 'Emplacement créé',
+      saved: 'Emplacement mis à jour',
+      deleted: 'Emplacements supprimés : {{count}}',
+      moved: 'Imprimantes déplacées : {{count}}',
+      failed: 'Impossible d\'enregistrer la modification : {{error}}',
+    },
     addPreflight: {
       checking: 'Vérification de la connexion...',
       warning: 'Certaines vérifications de connexion ont échoué. Cette imprimante pourrait apparaître hors ligne. Examinez les vérifications ci-dessous, corrigez ce que vous pouvez, ou enregistrez quand même.',

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Posizioni delle stampanti',
+      subtitle: '{{grouped}} con posizione, {{ungrouped}} senza',
+      search: 'Cerca posizioni...',
+      showEmpty: 'Mostra vuote',
+      hideEmpty: 'Nascondi vuote',
+      select: 'Seleziona',
+      done: 'Fatto',
+      sortNameAsc: 'Nome A→Z',
+      sortNameDesc: 'Nome Z→A',
+      sortCountAsc: 'Meno stampanti',
+      sortCountDesc: 'Più stampanti',
+      create: 'Nuova posizione',
+      createTitle: 'Crea posizione',
+      editTitle: 'Modifica posizione',
+      namePlaceholder: 'Nome della posizione',
+      icon: 'Icona',
+      color: 'Colore',
+      noColor: 'Nessun colore',
+      none: 'Ancora nessuna posizione delle stampanti',
+      noResults: 'Nessuna posizione corrisponde alla ricerca',
+      printerCount: 'Stampanti: {{count}}',
+      noPrinters: 'Nessuna stampante in questa posizione',
+      selectPrinter: 'Seleziona stampante',
+      move: 'Sposta in un\'altra posizione',
+      moveButton: 'Sposta',
+      removeFromLocation: 'Rimuovi dalla posizione',
+      ungroupedPrinters: 'Stampanti senza posizione: {{count}}',
+      selected: 'Selezionate: {{count}}',
+      deleteSelected: 'Elimina selezionate',
+      moveTitle: 'Sposta stampante',
+      moveTitleMany: 'Sposta stampanti ({{count}})',
+      targetPlaceholder: 'Seleziona una posizione...',
+      noLocation: 'Nessuna posizione',
+      deleteTitle: 'Elimina posizione',
+      deleteTitleMany: 'Elimina posizioni ({{count}})',
+      deleteMessage: 'Eliminare {{names}}? Stampanti che resteranno senza posizione: {{count}}.',
+      created: 'Posizione creata',
+      saved: 'Posizione aggiornata',
+      deleted: 'Posizioni eliminate: {{count}}',
+      moved: 'Stampanti spostate: {{count}}',
+      failed: 'Impossibile salvare la modifica: {{error}}',
+    },
     addPreflight: {
       checking: 'Verifica della connessione...',
       warning: 'Alcuni controlli di connessione non sono riusciti. Questa stampante potrebbe risultare offline. Controlla le verifiche qui sotto, risolvi ciò che puoi oppure salva comunque.',

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

@@ -236,6 +236,49 @@ export default {
   },
   // Printers page
   printers: {
+    locations: {
+      title: 'プリンターの場所',
+      subtitle: '場所あり {{grouped}} 台、場所なし {{ungrouped}} 台',
+      search: '場所を検索...',
+      showEmpty: '空の場所を表示',
+      hideEmpty: '空の場所を隠す',
+      select: '選択',
+      done: '完了',
+      sortNameAsc: '名前 A→Z',
+      sortNameDesc: '名前 Z→A',
+      sortCountAsc: 'プリンターが少ない順',
+      sortCountDesc: 'プリンターが多い順',
+      create: '新しい場所',
+      createTitle: '場所を作成',
+      editTitle: '場所を編集',
+      namePlaceholder: '場所の名前',
+      icon: 'アイコン',
+      color: '色',
+      noColor: '色なし',
+      none: 'プリンターの場所はまだありません',
+      noResults: '検索に一致する場所はありません',
+      printerCount: 'プリンター: {{count}} 台',
+      noPrinters: 'この場所にプリンターはありません',
+      selectPrinter: 'プリンターを選択',
+      move: '別の場所へ移動',
+      moveButton: '移動',
+      removeFromLocation: '場所から外す',
+      ungroupedPrinters: '場所のないプリンター: {{count}} 台',
+      selected: '選択中: {{count}}',
+      deleteSelected: '選択したものを削除',
+      moveTitle: 'プリンターを移動',
+      moveTitleMany: 'プリンターを移動 ({{count}} 台)',
+      targetPlaceholder: '場所を選択...',
+      noLocation: '場所なし',
+      deleteTitle: '場所を削除',
+      deleteTitleMany: '場所を削除 ({{count}})',
+      deleteMessage: '{{names}} を削除しますか?場所がなくなるプリンター: {{count}} 台',
+      created: '場所を作成しました',
+      saved: '場所を更新しました',
+      deleted: '削除した場所: {{count}}',
+      moved: '移動したプリンター: {{count}} 台',
+      failed: '変更を保存できませんでした: {{error}}',
+    },
     addPreflight: {
       checking: '接続を確認しています...',
       warning: '一部の接続チェックに失敗しました。このプリンターはオフラインと表示される可能性があります。下のチェックを確認し、可能な範囲で修正するか、そのまま保存してください。',

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

@@ -231,6 +231,49 @@ export default {
     deleteTransaction: '거래 삭제',
   },
   printers: {
+    locations: {
+      title: '프린터 위치',
+      subtitle: '위치 있음 {{grouped}}대, 위치 없음 {{ungrouped}}대',
+      search: '위치 검색...',
+      showEmpty: '빈 위치 표시',
+      hideEmpty: '빈 위치 숨기기',
+      select: '선택',
+      done: '완료',
+      sortNameAsc: '이름 A→Z',
+      sortNameDesc: '이름 Z→A',
+      sortCountAsc: '프린터 적은 순',
+      sortCountDesc: '프린터 많은 순',
+      create: '새 위치',
+      createTitle: '위치 만들기',
+      editTitle: '위치 편집',
+      namePlaceholder: '위치 이름',
+      icon: '아이콘',
+      color: '색상',
+      noColor: '색상 없음',
+      none: '아직 프린터 위치가 없습니다',
+      noResults: '검색과 일치하는 위치가 없습니다',
+      printerCount: '프린터: {{count}}대',
+      noPrinters: '이 위치에 프린터가 없습니다',
+      selectPrinter: '프린터 선택',
+      move: '다른 위치로 이동',
+      moveButton: '이동',
+      removeFromLocation: '위치에서 제거',
+      ungroupedPrinters: '위치가 없는 프린터: {{count}}대',
+      selected: '선택됨: {{count}}',
+      deleteSelected: '선택 항목 삭제',
+      moveTitle: '프린터 이동',
+      moveTitleMany: '프린터 이동 ({{count}}대)',
+      targetPlaceholder: '위치 선택...',
+      noLocation: '위치 없음',
+      deleteTitle: '위치 삭제',
+      deleteTitleMany: '위치 삭제 ({{count}})',
+      deleteMessage: '{{names}}을(를) 삭제할까요? 위치가 없어지는 프린터: {{count}}대',
+      created: '위치를 만들었습니다',
+      saved: '위치를 업데이트했습니다',
+      deleted: '삭제된 위치: {{count}}',
+      moved: '이동한 프린터: {{count}}대',
+      failed: '변경 사항을 저장하지 못했습니다: {{error}}',
+    },
     title: '프린터',
     addPrinter: '프린터 추가',
     editPrinter: '프린터 편집',

+ 43 - 0
frontend/src/i18n/locales/nl.ts

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Printerlocaties',
+      subtitle: '{{grouped}} met locatie, {{ungrouped}} zonder',
+      search: 'Locaties zoeken...',
+      showEmpty: 'Lege tonen',
+      hideEmpty: 'Lege verbergen',
+      select: 'Selecteren',
+      done: 'Klaar',
+      sortNameAsc: 'Naam A→Z',
+      sortNameDesc: 'Naam Z→A',
+      sortCountAsc: 'Minste printers',
+      sortCountDesc: 'Meeste printers',
+      create: 'Nieuwe locatie',
+      createTitle: 'Locatie aanmaken',
+      editTitle: 'Locatie bewerken',
+      namePlaceholder: 'Naam van de locatie',
+      icon: 'Pictogram',
+      color: 'Kleur',
+      noColor: 'Geen kleur',
+      none: 'Nog geen printerlocaties',
+      noResults: 'Geen locaties gevonden voor deze zoekopdracht',
+      printerCount: 'Aantal printers: {{count}}',
+      noPrinters: 'Geen printers op deze locatie',
+      selectPrinter: 'Printer selecteren',
+      move: 'Naar een andere locatie verplaatsen',
+      moveButton: 'Verplaatsen',
+      removeFromLocation: 'Uit locatie verwijderen',
+      ungroupedPrinters: 'Printers zonder locatie: {{count}}',
+      selected: 'Geselecteerd: {{count}}',
+      deleteSelected: 'Selectie verwijderen',
+      moveTitle: 'Printer verplaatsen',
+      moveTitleMany: 'Printers verplaatsen ({{count}})',
+      targetPlaceholder: 'Kies een locatie...',
+      noLocation: 'Geen locatie',
+      deleteTitle: 'Locatie verwijderen',
+      deleteTitleMany: 'Locaties verwijderen ({{count}})',
+      deleteMessage: '{{names}} verwijderen? Printers die daarna geen locatie hebben: {{count}}.',
+      created: 'Locatie aangemaakt',
+      saved: 'Locatie bijgewerkt',
+      deleted: 'Locaties verwijderd: {{count}}',
+      moved: 'Printers verplaatst: {{count}}',
+      failed: 'De wijziging kon niet worden opgeslagen: {{error}}',
+    },
     title: 'Printers',
     addPrinter: 'Printer toevoegen',
     addPreflight: {

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Locais das impressoras',
+      subtitle: '{{grouped}} com local, {{ungrouped}} sem local',
+      search: 'Buscar locais...',
+      showEmpty: 'Mostrar vazios',
+      hideEmpty: 'Ocultar vazios',
+      select: 'Selecionar',
+      done: 'Concluído',
+      sortNameAsc: 'Nome A→Z',
+      sortNameDesc: 'Nome Z→A',
+      sortCountAsc: 'Menos impressoras',
+      sortCountDesc: 'Mais impressoras',
+      create: 'Novo local',
+      createTitle: 'Criar local',
+      editTitle: 'Editar local',
+      namePlaceholder: 'Nome do local',
+      icon: 'Ícone',
+      color: 'Cor',
+      noColor: 'Sem cor',
+      none: 'Ainda não há locais de impressoras',
+      noResults: 'Nenhum local corresponde à busca',
+      printerCount: 'Impressoras: {{count}}',
+      noPrinters: 'Nenhuma impressora neste local',
+      selectPrinter: 'Selecionar impressora',
+      move: 'Mover para outro local',
+      moveButton: 'Mover',
+      removeFromLocation: 'Remover do local',
+      ungroupedPrinters: 'Impressoras sem local: {{count}}',
+      selected: 'Selecionados: {{count}}',
+      deleteSelected: 'Excluir selecionados',
+      moveTitle: 'Mover impressora',
+      moveTitleMany: 'Mover impressoras ({{count}})',
+      targetPlaceholder: 'Selecione um local...',
+      noLocation: 'Sem local',
+      deleteTitle: 'Excluir local',
+      deleteTitleMany: 'Excluir locais ({{count}})',
+      deleteMessage: 'Excluir {{names}}? Impressoras que ficarão sem local: {{count}}.',
+      created: 'Local criado',
+      saved: 'Local atualizado',
+      deleted: 'Locais excluídos: {{count}}',
+      moved: 'Impressoras movidas: {{count}}',
+      failed: 'Não foi possível salvar a alteração: {{error}}',
+    },
     addPreflight: {
       checking: 'Verificando a conexão...',
       warning: 'Algumas verificações de conexão falharam. Esta impressora pode aparecer como offline. Revise as verificações abaixo, corrija o que puder ou salve mesmo assim.',

+ 43 - 0
frontend/src/i18n/locales/ru.ts

@@ -231,6 +231,49 @@ export default {
     deleteTransaction: "Удалить операцию",
   },
   printers: {
+    locations: {
+      title: "Расположение принтеров",
+      subtitle: "С расположением: {{grouped}}, без расположения: {{ungrouped}}",
+      search: "Поиск расположений...",
+      showEmpty: "Показать пустые",
+      hideEmpty: "Скрыть пустые",
+      select: "Выбрать",
+      done: "Готово",
+      sortNameAsc: "По названию А→Я",
+      sortNameDesc: "По названию Я→А",
+      sortCountAsc: "Меньше принтеров",
+      sortCountDesc: "Больше принтеров",
+      create: "Новое расположение",
+      createTitle: "Создать расположение",
+      editTitle: "Изменить расположение",
+      namePlaceholder: "Название расположения",
+      icon: "Значок",
+      color: "Цвет",
+      noColor: "Без цвета",
+      none: "Расположений принтеров пока нет",
+      noResults: "Нет расположений, подходящих под запрос",
+      printerCount: "Принтеров: {{count}}",
+      noPrinters: "В этом расположении нет принтеров",
+      selectPrinter: "Выбрать принтер",
+      move: "Переместить в другое расположение",
+      moveButton: "Переместить",
+      removeFromLocation: "Убрать из расположения",
+      ungroupedPrinters: "Принтеры без расположения: {{count}}",
+      selected: "Выбрано: {{count}}",
+      deleteSelected: "Удалить выбранные",
+      moveTitle: "Переместить принтер",
+      moveTitleMany: "Переместить принтеры ({{count}})",
+      targetPlaceholder: "Выберите расположение...",
+      noLocation: "Без расположения",
+      deleteTitle: "Удалить расположение",
+      deleteTitleMany: "Удалить расположения ({{count}})",
+      deleteMessage: "Удалить {{names}}? Принтеров останется без расположения: {{count}}.",
+      created: "Расположение создано",
+      saved: "Расположение обновлено",
+      deleted: "Удалено расположений: {{count}}",
+      moved: "Перемещено принтеров: {{count}}",
+      failed: "Не удалось сохранить изменение: {{error}}",
+    },
     title: "Принтеры",
     addPrinter: "Добавить принтер",
     addPreflight: {

+ 43 - 0
frontend/src/i18n/locales/sv.ts

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: 'Skrivarplatser',
+      subtitle: '{{grouped}} med plats, {{ungrouped}} utan',
+      search: 'Sök platser...',
+      showEmpty: 'Visa tomma',
+      hideEmpty: 'Dölj tomma',
+      select: 'Välj',
+      done: 'Klar',
+      sortNameAsc: 'Namn A→Ö',
+      sortNameDesc: 'Namn Ö→A',
+      sortCountAsc: 'Minst antal skrivare',
+      sortCountDesc: 'Flest skrivare',
+      create: 'Ny plats',
+      createTitle: 'Skapa plats',
+      editTitle: 'Redigera plats',
+      namePlaceholder: 'Platsens namn',
+      icon: 'Ikon',
+      color: 'Färg',
+      noColor: 'Ingen färg',
+      none: 'Inga skrivarplatser ännu',
+      noResults: 'Inga platser matchar sökningen',
+      printerCount: 'Skrivare: {{count}}',
+      noPrinters: 'Inga skrivare på den här platsen',
+      selectPrinter: 'Välj skrivare',
+      move: 'Flytta till en annan plats',
+      moveButton: 'Flytta',
+      removeFromLocation: 'Ta bort från platsen',
+      ungroupedPrinters: 'Skrivare utan plats: {{count}}',
+      selected: 'Valda: {{count}}',
+      deleteSelected: 'Ta bort valda',
+      moveTitle: 'Flytta skrivare',
+      moveTitleMany: 'Flytta skrivare ({{count}})',
+      targetPlaceholder: 'Välj en plats...',
+      noLocation: 'Ingen plats',
+      deleteTitle: 'Ta bort plats',
+      deleteTitleMany: 'Ta bort platser ({{count}})',
+      deleteMessage: 'Ta bort {{names}}? Skrivare som blir utan plats: {{count}}.',
+      created: 'Platsen har skapats',
+      saved: 'Platsen har uppdaterats',
+      deleted: 'Borttagna platser: {{count}}',
+      moved: 'Flyttade skrivare: {{count}}',
+      failed: 'Det gick inte att spara ändringen: {{error}}',
+    },
     title: 'Skrivare',
     addPrinter: 'Lägg till skrivare',
     addPreflight: {

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

@@ -237,6 +237,49 @@ export default {
 
   // Yazıcılar sayfası
   printers: {
+    locations: {
+      title: 'Yazıcı konumları',
+      subtitle: '{{grouped}} konumlu, {{ungrouped}} konumsuz',
+      search: 'Konum ara...',
+      showEmpty: 'Boşları göster',
+      hideEmpty: 'Boşları gizle',
+      select: 'Seç',
+      done: 'Bitti',
+      sortNameAsc: 'Ad A→Z',
+      sortNameDesc: 'Ad Z→A',
+      sortCountAsc: 'En az yazıcı',
+      sortCountDesc: 'En çok yazıcı',
+      create: 'Yeni konum',
+      createTitle: 'Konum oluştur',
+      editTitle: 'Konumu düzenle',
+      namePlaceholder: 'Konum adı',
+      icon: 'Simge',
+      color: 'Renk',
+      noColor: 'Renk yok',
+      none: 'Henüz yazıcı konumu yok',
+      noResults: 'Aramayla eşleşen konum yok',
+      printerCount: 'Yazıcı: {{count}}',
+      noPrinters: 'Bu konumda yazıcı yok',
+      selectPrinter: 'Yazıcıyı seç',
+      move: 'Başka bir konuma taşı',
+      moveButton: 'Taşı',
+      removeFromLocation: 'Konumdan çıkar',
+      ungroupedPrinters: 'Konumu olmayan yazıcılar: {{count}}',
+      selected: 'Seçili: {{count}}',
+      deleteSelected: 'Seçilenleri sil',
+      moveTitle: 'Yazıcıyı taşı',
+      moveTitleMany: 'Yazıcıları taşı ({{count}})',
+      targetPlaceholder: 'Bir konum seçin...',
+      noLocation: 'Konum yok',
+      deleteTitle: 'Konumu sil',
+      deleteTitleMany: 'Konumları sil ({{count}})',
+      deleteMessage: '{{names}} silinsin mi? Konumsuz kalacak yazıcı: {{count}}.',
+      created: 'Konum oluşturuldu',
+      saved: 'Konum güncellendi',
+      deleted: 'Silinen konum: {{count}}',
+      moved: 'Taşınan yazıcı: {{count}}',
+      failed: 'Değişiklik kaydedilemedi: {{error}}',
+    },
     title: 'Yazıcılar',
     addPrinter: 'Yazıcı Ekle',
     addPreflight: {

+ 43 - 0
frontend/src/i18n/locales/uk.ts

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: "Розташування принтерів",
+      subtitle: "З розташуванням: {{grouped}}, без розташування: {{ungrouped}}",
+      search: "Пошук розташувань...",
+      showEmpty: "Показати порожні",
+      hideEmpty: "Сховати порожні",
+      select: "Вибрати",
+      done: "Готово",
+      sortNameAsc: "За назвою А→Я",
+      sortNameDesc: "За назвою Я→А",
+      sortCountAsc: "Менше принтерів",
+      sortCountDesc: "Більше принтерів",
+      create: "Нове розташування",
+      createTitle: "Створити розташування",
+      editTitle: "Редагувати розташування",
+      namePlaceholder: "Назва розташування",
+      icon: "Значок",
+      color: "Колір",
+      noColor: "Без кольору",
+      none: "Розташувань принтерів ще немає",
+      noResults: "Немає розташувань, що відповідають пошуку",
+      printerCount: "Принтерів: {{count}}",
+      noPrinters: "У цьому розташуванні немає принтерів",
+      selectPrinter: "Вибрати принтер",
+      move: "Перемістити в інше розташування",
+      moveButton: "Перемістити",
+      removeFromLocation: "Прибрати з розташування",
+      ungroupedPrinters: "Принтери без розташування: {{count}}",
+      selected: "Вибрано: {{count}}",
+      deleteSelected: "Видалити вибрані",
+      moveTitle: "Перемістити принтер",
+      moveTitleMany: "Перемістити принтери ({{count}})",
+      targetPlaceholder: "Виберіть розташування...",
+      noLocation: "Без розташування",
+      deleteTitle: "Видалити розташування",
+      deleteTitleMany: "Видалити кілька розташувань ({{count}})",
+      deleteMessage: "Видалити {{names}}? Принтерів залишиться без розташування: {{count}}.",
+      created: "Розташування створено",
+      saved: "Розташування оновлено",
+      deleted: "Видалено розташувань: {{count}}",
+      moved: "Переміщено принтерів: {{count}}",
+      failed: "Не вдалося зберегти зміну: {{error}}",
+    },
     title: "Принтери",
     addPrinter: "Додати принтер",
     addPreflight: {

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: '打印机位置',
+      subtitle: '有位置 {{grouped}} 台,无位置 {{ungrouped}} 台',
+      search: '搜索位置...',
+      showEmpty: '显示空位置',
+      hideEmpty: '隐藏空位置',
+      select: '选择',
+      done: '完成',
+      sortNameAsc: '名称 A→Z',
+      sortNameDesc: '名称 Z→A',
+      sortCountAsc: '打印机最少',
+      sortCountDesc: '打印机最多',
+      create: '新建位置',
+      createTitle: '创建位置',
+      editTitle: '编辑位置',
+      namePlaceholder: '位置名称',
+      icon: '图标',
+      color: '颜色',
+      noColor: '无颜色',
+      none: '还没有打印机位置',
+      noResults: '没有与搜索匹配的位置',
+      printerCount: '打印机:{{count}} 台',
+      noPrinters: '此位置没有打印机',
+      selectPrinter: '选择打印机',
+      move: '移动到其他位置',
+      moveButton: '移动',
+      removeFromLocation: '从位置中移除',
+      ungroupedPrinters: '没有位置的打印机:{{count}} 台',
+      selected: '已选择:{{count}}',
+      deleteSelected: '删除所选',
+      moveTitle: '移动打印机',
+      moveTitleMany: '移动打印机({{count}} 台)',
+      targetPlaceholder: '选择位置...',
+      noLocation: '无位置',
+      deleteTitle: '删除位置',
+      deleteTitleMany: '删除多个位置({{count}})',
+      deleteMessage: '删除 {{names}}?将失去位置的打印机:{{count}} 台。',
+      created: '位置已创建',
+      saved: '位置已更新',
+      deleted: '已删除位置:{{count}}',
+      moved: '已移动打印机:{{count}} 台',
+      failed: '无法保存更改:{{error}}',
+    },
     addPreflight: {
       checking: '正在检查连接...',
       warning: '部分连接检查未通过。此打印机可能显示为离线。请查看下方的检查项,尽量修复,或仍然保存。',

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

@@ -237,6 +237,49 @@ export default {
 
   // Printers page
   printers: {
+    locations: {
+      title: '印表機位置',
+      subtitle: '有位置 {{grouped}} 台,無位置 {{ungrouped}} 台',
+      search: '搜尋位置...',
+      showEmpty: '顯示空位置',
+      hideEmpty: '隱藏空位置',
+      select: '選取',
+      done: '完成',
+      sortNameAsc: '名稱 A→Z',
+      sortNameDesc: '名稱 Z→A',
+      sortCountAsc: '印表機最少',
+      sortCountDesc: '印表機最多',
+      create: '新增位置',
+      createTitle: '建立位置',
+      editTitle: '編輯位置',
+      namePlaceholder: '位置名稱',
+      icon: '圖示',
+      color: '顏色',
+      noColor: '無顏色',
+      none: '尚無印表機位置',
+      noResults: '沒有符合搜尋的位置',
+      printerCount: '印表機:{{count}} 台',
+      noPrinters: '此位置沒有印表機',
+      selectPrinter: '選取印表機',
+      move: '移到其他位置',
+      moveButton: '移動',
+      removeFromLocation: '從位置中移除',
+      ungroupedPrinters: '沒有位置的印表機:{{count}} 台',
+      selected: '已選取:{{count}}',
+      deleteSelected: '刪除所選',
+      moveTitle: '移動印表機',
+      moveTitleMany: '移動印表機({{count}} 台)',
+      targetPlaceholder: '選取位置...',
+      noLocation: '無位置',
+      deleteTitle: '刪除位置',
+      deleteTitleMany: '刪除多個位置({{count}})',
+      deleteMessage: '刪除 {{names}}?將失去位置的印表機:{{count}} 台。',
+      created: '位置已建立',
+      saved: '位置已更新',
+      deleted: '已刪除位置:{{count}}',
+      moved: '已移動印表機:{{count}} 台',
+      failed: '無法儲存變更:{{error}}',
+    },
     addPreflight: {
       checking: '正在檢查連線...',
       warning: '部分連線檢查未通過。此印表機可能顯示為離線。請查看下方的檢查項目,盡量修復,或仍然儲存。',

+ 723 - 0
frontend/src/pages/PrinterLocationsPage.tsx

@@ -0,0 +1,723 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Box, CheckSquare, ChevronDown, Loader2, Move, Pencil, Plus, Search, Square, Trash2, UserMinus, X } from 'lucide-react';
+import { api } from '../api/client';
+import type { Printer, PrinterLocation } from '../api/client';
+import { Button } from '../components/Button';
+import { Card, CardContent } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { IconPicker, AVAILABLE_ICONS } from '../components/IconPicker';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+
+// Matches printers.location / printer_locations.name (VARCHAR(100)).
+const LOCATION_NAME_MAX_LENGTH = 100;
+
+const LOCATION_COLORS = [
+  '#ef4444', // red
+  '#f97316', // orange
+  '#eab308', // yellow
+  '#22c55e', // green
+  '#14b8a6', // teal
+  '#3b82f6', // blue
+  '#8b5cf6', // violet
+  '#ec4899', // pink
+  '#6b7280', // gray
+];
+
+type SortMode = 'name-asc' | 'name-desc' | 'count-asc' | 'count-desc';
+const SORT_MODES: SortMode[] = ['name-asc', 'name-desc', 'count-asc', 'count-desc'];
+
+// Per-viewer conveniences only; the locations themselves live on the server.
+const HIDE_EMPTY_KEY = 'printerLocations.hideEmpty';
+const SORT_KEY = 'printerLocations.sort';
+
+function readStored<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
+  try {
+    const value = localStorage.getItem(key);
+    return allowed.includes(value as T) ? (value as T) : fallback;
+  } catch {
+    return fallback;
+  }
+}
+
+function writeStored(key: string, value: string) {
+  try {
+    localStorage.setItem(key, value);
+  } catch {
+    // Private mode or storage disabled: the setting just is not remembered.
+  }
+}
+
+const locationOf = (printer: Printer) => printer.location?.trim() || '';
+
+function LocationIcon({ location }: { location: Pick<PrinterLocation, 'icon' | 'color'> }) {
+  const Icon = AVAILABLE_ICONS.find((i) => i.name === location.icon)?.icon ?? Box;
+  return (
+    <div className="p-2 rounded-lg bg-bambu-dark relative overflow-hidden flex-shrink-0">
+      <Icon className="w-[25px] h-[25px] text-bambu-gray" />
+      {location.color && (
+        <div className="absolute top-0 left-0 w-1 h-full" style={{ backgroundColor: location.color }} />
+      )}
+    </div>
+  );
+}
+
+interface PrinterRowProps {
+  printer: Printer;
+  status?: { connected?: boolean; state?: string | null };
+  selected: boolean;
+  canEdit: boolean;
+  busy: boolean;
+  onToggle: () => void;
+  onMove: () => void;
+  onRemove?: () => void;
+}
+
+function PrinterRow({ printer, status, selected, canEdit, busy, onToggle, onMove, onRemove }: PrinterRowProps) {
+  const { t } = useTranslation();
+  const state = status?.state;
+  const connected = status?.connected;
+  const label = !connected
+    ? t('printers.status.offline')
+    : state === 'RUNNING'
+      ? t('printers.status.printing')
+      : state === 'PAUSE'
+        ? t('printers.status.paused')
+        : state === 'FINISH'
+          ? t('printers.status.finished')
+          : t('printers.status.idle');
+  const badge = !connected
+    ? 'bg-gray-500/20 text-gray-400'
+    : state === 'RUNNING'
+      ? 'bg-orange-500/20 text-orange-400'
+      : state === 'PAUSE'
+        ? 'bg-yellow-500/20 text-yellow-400'
+        : state === 'FINISH'
+          ? 'bg-bambu-green/20 text-bambu-green'
+          : 'bg-bambu-dark text-bambu-gray';
+
+  return (
+    <div
+      className={`flex items-center justify-between gap-2 py-2 px-3 rounded-lg transition-colors ${
+        selected ? 'bg-bambu-green/10 border border-bambu-green/30' : 'bg-bambu-dark-secondary hover:bg-bambu-dark'
+      }`}
+    >
+      <div className="flex items-center gap-3 min-w-0">
+        {canEdit && (
+          <button
+            type="button"
+            onClick={onToggle}
+            className="text-bambu-gray hover:text-bambu-green transition-colors"
+            title={t('printers.locations.selectPrinter')}
+            aria-label={t('printers.locations.selectPrinter')}
+            aria-pressed={selected}
+          >
+            {selected ? <CheckSquare className="w-4 h-4 text-bambu-green" /> : <Square className="w-4 h-4" />}
+          </button>
+        )}
+        <div
+          className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
+            connected ? (state === 'RUNNING' || state === 'PAUSE' ? 'bg-orange-500' : 'bg-bambu-green') : 'bg-gray-500'
+          }`}
+        />
+        <div className="min-w-0">
+          <p className="text-white text-sm font-medium truncate">{printer.name}</p>
+          <p className="text-xs text-bambu-gray">{printer.model || t('printers.status.unknown')}</p>
+        </div>
+      </div>
+      <div className="flex items-center gap-2 flex-shrink-0">
+        <span className={`text-xs px-2 py-1 rounded-full ${badge}`}>{label}</span>
+        {canEdit && (
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={onMove}
+            disabled={busy}
+            className="text-bambu-gray hover:text-bambu-green hover:bg-bambu-green/10"
+            title={t('printers.locations.move')}
+            aria-label={t('printers.locations.move')}
+          >
+            <Move className="w-3.5 h-3.5" />
+          </Button>
+        )}
+        {canEdit && onRemove && (
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={onRemove}
+            disabled={busy}
+            className="text-bambu-gray hover:text-red-400 hover:bg-red-500/10"
+            title={t('printers.locations.removeFromLocation')}
+            aria-label={t('printers.locations.removeFromLocation')}
+          >
+            <UserMinus className="w-3.5 h-3.5" />
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+interface LocationDialogProps {
+  initial?: PrinterLocation;
+  saving: boolean;
+  onSave: (value: { name: string; icon: string; color: string }) => void;
+  onCancel: () => void;
+}
+
+function LocationDialog({ initial, saving, onSave, onCancel }: LocationDialogProps) {
+  const { t } = useTranslation();
+  const [name, setName] = useState(initial?.name ?? '');
+  const [icon, setIcon] = useState(initial?.icon ?? '');
+  const [color, setColor] = useState(initial?.color ?? '');
+  const canSave = name.trim().length > 0 && !saving;
+  const save = () => canSave && onSave({ name: name.trim(), icon, color });
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-md">
+        <CardContent className="space-y-4">
+          <h2 className="text-lg font-semibold text-white">
+            {initial ? t('printers.locations.editTitle') : t('printers.locations.createTitle')}
+          </h2>
+          <input
+            type="text"
+            value={name}
+            maxLength={LOCATION_NAME_MAX_LENGTH}
+            onChange={(e) => setName(e.target.value)}
+            placeholder={t('printers.locations.namePlaceholder')}
+            aria-label={t('printers.locations.namePlaceholder')}
+            className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+            autoFocus
+            onKeyDown={(e) => {
+              if (e.key === 'Enter') save();
+              if (e.key === 'Escape') onCancel();
+            }}
+          />
+          <div>
+            <p className="text-sm text-bambu-gray mb-2">{t('printers.locations.icon')}</p>
+            <IconPicker value={icon} onChange={setIcon} />
+          </div>
+          <div>
+            <p className="text-sm text-bambu-gray mb-2">{t('printers.locations.color')}</p>
+            <div className="flex gap-2 flex-wrap">
+              {LOCATION_COLORS.map((c) => (
+                <button
+                  key={c}
+                  type="button"
+                  onClick={() => setColor(color === c ? '' : c)}
+                  className={`w-8 h-8 rounded-lg transition-all ${
+                    color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : 'hover:scale-105'
+                  }`}
+                  style={{ backgroundColor: c }}
+                  title={c}
+                  aria-label={c}
+                  aria-pressed={color === c}
+                />
+              ))}
+              {color && (
+                <button
+                  type="button"
+                  onClick={() => setColor('')}
+                  className="w-8 h-8 rounded-lg border-2 border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-gray hover:text-white hover:border-bambu-gray transition-all flex items-center justify-center"
+                  title={t('printers.locations.noColor')}
+                  aria-label={t('printers.locations.noColor')}
+                >
+                  <X className="w-3.5 h-3.5" />
+                </button>
+              )}
+            </div>
+          </div>
+          <div className="flex gap-2 justify-end">
+            <Button variant="secondary" onClick={onCancel} disabled={saving}>
+              {t('common.cancel')}
+            </Button>
+            <Button onClick={save} disabled={!canSave}>
+              {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : initial ? t('common.save') : t('printers.locations.create')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+interface MoveDialogProps {
+  count: number;
+  printerName?: string;
+  locations: PrinterLocation[];
+  saving: boolean;
+  onMove: (location: string | null) => void;
+  onCancel: () => void;
+}
+
+const NO_LOCATION = '__none__';
+
+function MoveDialog({ count, printerName, locations, saving, onMove, onCancel }: MoveDialogProps) {
+  const { t } = useTranslation();
+  const [target, setTarget] = useState('');
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-md">
+        <CardContent className="space-y-4">
+          <h2 className="text-lg font-semibold text-white">
+            {count === 1 ? t('printers.locations.moveTitle') : t('printers.locations.moveTitleMany', { count })}
+          </h2>
+          {printerName && <p className="text-sm text-bambu-gray">{printerName}</p>}
+          <select
+            value={target}
+            onChange={(e) => setTarget(e.target.value)}
+            aria-label={t('printers.locations.targetPlaceholder')}
+            className="w-full px-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+            disabled={saving}
+            autoFocus
+          >
+            <option value="" disabled>
+              {t('printers.locations.targetPlaceholder')}
+            </option>
+            {locations.map((loc) => (
+              <option key={loc.name} value={loc.name}>
+                {loc.name}
+              </option>
+            ))}
+            <option value={NO_LOCATION}>{t('printers.locations.noLocation')}</option>
+          </select>
+          <div className="flex gap-2 justify-end">
+            <Button variant="secondary" onClick={onCancel} disabled={saving}>
+              {t('common.cancel')}
+            </Button>
+            <Button onClick={() => onMove(target === NO_LOCATION ? null : target)} disabled={saving || !target}>
+              {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('printers.locations.moveButton')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+export function PrinterLocationsPage() {
+  const queryClient = useQueryClient();
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+  const canEdit = hasPermission('printers:update');
+
+  const [search, setSearch] = useState('');
+  const [hideEmpty, setHideEmpty] = useState(() => readStored(HIDE_EMPTY_KEY, ['true', 'false'], 'false') === 'true');
+  const [sortMode, setSortMode] = useState<SortMode>(() => readStored(SORT_KEY, SORT_MODES, 'name-asc'));
+  const [showSortMenu, setShowSortMenu] = useState(false);
+  const [expanded, setExpanded] = useState<string | null>(null);
+  const [selectingLocations, setSelectingLocations] = useState(false);
+  const [selectedLocations, setSelectedLocations] = useState<Set<string>>(new Set());
+  const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
+  const [dialog, setDialog] = useState<{ mode: 'create' } | { mode: 'edit'; location: PrinterLocation } | null>(null);
+  const [moving, setMoving] = useState<{ ids: number[]; printerName?: string } | null>(null);
+  const [deleting, setDeleting] = useState<string[] | null>(null);
+
+  useEffect(() => writeStored(HIDE_EMPTY_KEY, String(hideEmpty)), [hideEmpty]);
+  useEffect(() => writeStored(SORT_KEY, sortMode), [sortMode]);
+  useEffect(() => {
+    if (selectingLocations) setExpanded(null);
+  }, [selectingLocations]);
+
+  const { data: printers, isLoading: printersLoading } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
+  const { data: locations, isLoading: locationsLoading } = useQuery({
+    queryKey: ['printer-locations'],
+    queryFn: api.getPrinterLocations,
+  });
+
+  // Same query the printer cards use, so the cache and the WebSocket updates
+  // are shared rather than polled twice.
+  const statusQueries = useQueries({
+    queries: (printers ?? []).map((p) => ({
+      queryKey: ['printerStatus', p.id],
+      queryFn: () => api.getPrinterStatus(p.id),
+      refetchInterval: 30000,
+    })),
+  });
+  const statusById = new Map((printers ?? []).map((p, i) => [p.id, statusQueries[i]?.data]));
+
+  const printersByLocation = useMemo(() => {
+    const map = new Map<string, Printer[]>();
+    for (const p of printers ?? []) {
+      const key = locationOf(p);
+      map.set(key, [...(map.get(key) ?? []), p]);
+    }
+    return map;
+  }, [printers]);
+  const ungrouped = printersByLocation.get('') ?? [];
+  const groupedCount = (printers?.length ?? 0) - ungrouped.length;
+
+  const displayed = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    const list = (locations ?? []).filter(
+      (loc) => (!q || loc.name.toLowerCase().includes(q)) && (!hideEmpty || loc.printer_count > 0),
+    );
+    const byName = (a: PrinterLocation, b: PrinterLocation) =>
+      a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
+    return [...list].sort((a, b) => {
+      switch (sortMode) {
+        case 'name-desc':
+          return byName(b, a);
+        case 'count-asc':
+          return a.printer_count - b.printer_count || byName(a, b);
+        case 'count-desc':
+          return b.printer_count - a.printer_count || byName(a, b);
+        default:
+          return byName(a, b);
+      }
+    });
+  }, [locations, search, hideEmpty, sortMode]);
+
+  const refresh = () => {
+    queryClient.invalidateQueries({ queryKey: ['printer-locations'] });
+    queryClient.invalidateQueries({ queryKey: ['printers'] });
+  };
+  const fail = (error: unknown) =>
+    showToast(t('printers.locations.failed', { error: error instanceof Error ? error.message : String(error) }), 'error');
+
+  const saveMutation = useMutation({
+    mutationFn: ({ value, location }: { value: { name: string; icon: string; color: string }; location?: PrinterLocation }) =>
+      location
+        ? api.updatePrinterLocation({
+            name: location.name,
+            ...(value.name !== location.name ? { new_name: value.name } : {}),
+            icon: value.icon || null,
+            color: value.color || null,
+          })
+        : api.createPrinterLocation({ name: value.name, icon: value.icon || null, color: value.color || null }),
+    onSuccess: (saved, { location }) => {
+      refresh();
+      if (location && expanded === location.name) setExpanded(saved.name);
+      showToast(location ? t('printers.locations.saved') : t('printers.locations.created'));
+      setDialog(null);
+    },
+    onError: fail,
+  });
+
+  const moveMutation = useMutation({
+    mutationFn: ({ ids, location }: { ids: number[]; location: string | null }) => api.assignPrinterLocation(ids, location),
+    onSuccess: ({ moved }) => {
+      refresh();
+      showToast(t('printers.locations.moved', { count: moved }));
+      setSelectedPrinters(new Set());
+      setMoving(null);
+    },
+    onError: fail,
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (names: string[]) => api.deletePrinterLocations(names),
+    onSuccess: ({ deleted }) => {
+      refresh();
+      showToast(t('printers.locations.deleted', { count: deleted }));
+      setSelectedLocations(new Set());
+      setSelectingLocations(false);
+      setDeleting(null);
+    },
+    onError: (error) => {
+      fail(error);
+      setDeleting(null);
+    },
+  });
+
+  const busy = moveMutation.isPending || deleteMutation.isPending || saveMutation.isPending;
+
+  const toggle = <T,>(set: Set<T>, value: T) => {
+    const next = new Set(set);
+    if (next.has(value)) next.delete(value);
+    else next.add(value);
+    return next;
+  };
+
+  if (printersLoading || locationsLoading) {
+    return (
+      <div className="flex items-center justify-center h-64">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  const deletingPrinterCount = (deleting ?? []).reduce(
+    (sum, name) => sum + (locations?.find((l) => l.name === name)?.printer_count ?? 0),
+    0,
+  );
+  const sortLabel = (mode: SortMode) =>
+    ({
+      'name-asc': t('printers.locations.sortNameAsc'),
+      'name-desc': t('printers.locations.sortNameDesc'),
+      'count-asc': t('printers.locations.sortCountAsc'),
+      'count-desc': t('printers.locations.sortCountDesc'),
+    })[mode];
+
+  const renderPrinter = (printer: Printer, removable: boolean) => (
+    <PrinterRow
+      key={printer.id}
+      printer={printer}
+      status={statusById.get(printer.id)}
+      selected={selectedPrinters.has(printer.id)}
+      canEdit={canEdit && !selectingLocations}
+      busy={busy}
+      onToggle={() => setSelectedPrinters(toggle(selectedPrinters, printer.id))}
+      onMove={() => setMoving({ ids: [printer.id], printerName: printer.name })}
+      onRemove={removable ? () => moveMutation.mutate({ ids: [printer.id], location: null }) : undefined}
+    />
+  );
+
+  const allUngroupedSelected = ungrouped.length > 0 && ungrouped.every((p) => selectedPrinters.has(p.id));
+
+  return (
+    <div className="p-4 md:p-8 pb-28">
+      <div className="mb-4">
+        <div className="flex items-center gap-3 mb-1">
+          <Box className="w-[25px] h-[25px] text-bambu-green" />
+          <h1 className="text-2xl font-bold text-white">{t('printers.locations.title')}</h1>
+        </div>
+        <p className="text-sm text-bambu-gray">
+          {t('printers.locations.subtitle', { grouped: groupedCount, ungrouped: ungrouped.length })}
+        </p>
+      </div>
+
+      <div className="flex flex-wrap gap-3 mb-6">
+        <div className="relative flex-1 min-w-[12rem]">
+          <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
+          <input
+            type="text"
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            placeholder={t('printers.locations.search')}
+            aria-label={t('printers.locations.search')}
+            className="w-full pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+          />
+        </div>
+        <Button onClick={() => setHideEmpty(!hideEmpty)} variant={hideEmpty ? 'primary' : 'secondary'}>
+          {hideEmpty ? t('printers.locations.showEmpty') : t('printers.locations.hideEmpty')}
+        </Button>
+        {canEdit && (
+          <Button
+            onClick={() => {
+              setSelectedLocations(new Set());
+              setSelectingLocations(!selectingLocations);
+            }}
+            variant={selectingLocations ? 'primary' : 'secondary'}
+          >
+            <CheckSquare className="w-4 h-4 mr-1" />
+            {selectingLocations ? t('printers.locations.done') : t('printers.locations.select')}
+          </Button>
+        )}
+        <div className="relative">
+          <Button onClick={() => setShowSortMenu(!showSortMenu)} variant="secondary" aria-haspopup="menu">
+            <ChevronDown className={`w-4 h-4 mr-1 transition-transform ${showSortMenu ? 'rotate-180' : ''}`} />
+            {sortLabel(sortMode)}
+          </Button>
+          {showSortMenu && (
+            <>
+              <div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
+              <div role="menu" className="absolute right-0 top-full mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-1 min-w-[10rem]">
+                {SORT_MODES.map((mode) => (
+                  <button
+                    key={mode}
+                    role="menuitem"
+                    onClick={() => {
+                      setSortMode(mode);
+                      setShowSortMenu(false);
+                    }}
+                    className={`w-full text-left px-3 py-2 text-sm rounded transition-colors ${
+                      sortMode === mode ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'
+                    }`}
+                  >
+                    {sortLabel(mode)}
+                  </button>
+                ))}
+              </div>
+            </>
+          )}
+        </div>
+        {canEdit && (
+          <Button onClick={() => setDialog({ mode: 'create' })}>
+            <Plus className="w-4 h-4 mr-1" />
+            {t('printers.locations.create')}
+          </Button>
+        )}
+      </div>
+
+      {displayed.length === 0 && ungrouped.length === 0 ? (
+        <Card>
+          <CardContent className="text-center py-12 text-bambu-gray">
+            {search ? t('printers.locations.noResults') : t('printers.locations.none')}
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="space-y-3">
+          {displayed.map((loc) => {
+            const isExpanded = expanded === loc.name;
+            const isSelected = selectedLocations.has(loc.name);
+            return (
+              <Card key={loc.name}>
+                <CardContent className="p-4">
+                  <div className="flex items-center justify-between gap-2">
+                    <button
+                      type="button"
+                      onClick={() =>
+                        selectingLocations
+                          ? setSelectedLocations(toggle(selectedLocations, loc.name))
+                          : setExpanded(isExpanded ? null : loc.name)
+                      }
+                      className="flex items-center gap-3 flex-1 min-w-0 text-left"
+                      aria-expanded={selectingLocations ? undefined : isExpanded}
+                      aria-pressed={selectingLocations ? isSelected : undefined}
+                    >
+                      {selectingLocations ? (
+                        isSelected ? (
+                          <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                        ) : (
+                          <Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                        )
+                      ) : (
+                        <ChevronDown
+                          className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
+                        />
+                      )}
+                      <LocationIcon location={loc} />
+                      <div className="min-w-0">
+                        <p className="text-white font-medium truncate">{loc.name}</p>
+                        <p className="text-sm text-bambu-gray">{t('printers.locations.printerCount', { count: loc.printer_count })}</p>
+                      </div>
+                    </button>
+                    {canEdit && !selectingLocations && (
+                      <div className="flex items-center gap-2">
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => setDialog({ mode: 'edit', location: loc })}
+                          disabled={busy}
+                          className="text-bambu-gray hover:text-blue-400 hover:bg-blue-500/10"
+                          title={t('common.edit')}
+                          aria-label={t('common.edit')}
+                        >
+                          <Pencil className="w-4 h-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => setDeleting([loc.name])}
+                          disabled={busy}
+                          className="text-red-500 hover:text-red-400 hover:bg-red-500/10"
+                          title={t('common.delete')}
+                          aria-label={t('common.delete')}
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    )}
+                  </div>
+                  {isExpanded && (
+                    <div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-2">
+                      {(printersByLocation.get(loc.name) ?? []).length === 0 ? (
+                        <p className="text-sm text-bambu-gray text-center py-4">{t('printers.locations.noPrinters')}</p>
+                      ) : (
+                        (printersByLocation.get(loc.name) ?? []).map((p) => renderPrinter(p, true))
+                      )}
+                    </div>
+                  )}
+                </CardContent>
+              </Card>
+            );
+          })}
+
+          {ungrouped.length > 0 && (
+            <div className="pt-4 border-t border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between mb-3">
+                <h3 className="text-sm font-medium text-bambu-gray">
+                  {t('printers.locations.ungroupedPrinters', { count: ungrouped.length })}
+                </h3>
+                {canEdit && !selectingLocations && (
+                  <button
+                    type="button"
+                    onClick={() => {
+                      const next = new Set(selectedPrinters);
+                      ungrouped.forEach((p) => (allUngroupedSelected ? next.delete(p.id) : next.add(p.id)));
+                      setSelectedPrinters(next);
+                    }}
+                    className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors"
+                  >
+                    {allUngroupedSelected ? t('common.deselectAll') : t('common.selectAll')}
+                  </button>
+                )}
+              </div>
+              <div className="space-y-2">{ungrouped.map((p) => renderPrinter(p, false))}</div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {canEdit && !selectingLocations && selectedPrinters.size > 0 && (
+        <div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
+          <span className="text-white text-sm">{t('printers.locations.selected', { count: selectedPrinters.size })}</span>
+          <Button onClick={() => setMoving({ ids: Array.from(selectedPrinters) })} disabled={busy}>
+            <Move className="w-4 h-4 mr-1" />
+            {t('printers.locations.moveButton')}
+          </Button>
+          <Button variant="ghost" onClick={() => setSelectedPrinters(new Set())} disabled={busy}>
+            {t('common.cancel')}
+          </Button>
+        </div>
+      )}
+
+      {selectingLocations && selectedLocations.size > 0 && (
+        <div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-bambu-dark border border-bambu-dark-tertiary rounded-xl shadow-2xl px-6 py-4 flex items-center gap-4 z-40">
+          <span className="text-white text-sm">{t('printers.locations.selected', { count: selectedLocations.size })}</span>
+          <Button variant="danger" onClick={() => setDeleting(Array.from(selectedLocations))} disabled={busy}>
+            <Trash2 className="w-4 h-4 mr-1" />
+            {t('printers.locations.deleteSelected')}
+          </Button>
+          <Button variant="ghost" onClick={() => setSelectedLocations(new Set())} disabled={busy}>
+            {t('common.cancel')}
+          </Button>
+        </div>
+      )}
+
+      {dialog && (
+        <LocationDialog
+          initial={dialog.mode === 'edit' ? dialog.location : undefined}
+          saving={saveMutation.isPending}
+          onSave={(value) => saveMutation.mutate({ value, location: dialog.mode === 'edit' ? dialog.location : undefined })}
+          onCancel={() => setDialog(null)}
+        />
+      )}
+
+      {moving && (
+        <MoveDialog
+          count={moving.ids.length}
+          printerName={moving.printerName}
+          locations={locations ?? []}
+          saving={moveMutation.isPending}
+          onMove={(location) => moveMutation.mutate({ ids: moving.ids, location })}
+          onCancel={() => setMoving(null)}
+        />
+      )}
+
+      {deleting && (
+        <ConfirmModal
+          title={
+            deleting.length === 1
+              ? t('printers.locations.deleteTitle')
+              : t('printers.locations.deleteTitleMany', { count: deleting.length })
+          }
+          message={t('printers.locations.deleteMessage', {
+            count: deletingPrinterCount,
+            names: deleting.join(', '),
+          })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(deleting)}
+          onCancel={() => setDeleting(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 10 - 0
frontend/src/pages/PrintersPage.tsx

@@ -8080,6 +8080,7 @@ export function AddPrinterModal({
                 value={form.location || ''}
                 onChange={(e) => setForm({ ...form, location: e.target.value })}
                 placeholder={t('printers.modal.locationPlaceholder')}
+                maxLength={100}
               />
               <p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
             </div>
@@ -8611,6 +8612,7 @@ function EditPrinterModal({
                 value={form.location}
                 onChange={(e) => setForm({ ...form, location: e.target.value })}
                 placeholder={t('printers.modal.locationPlaceholder')}
+                maxLength={100}
               />
               <p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
             </div>
@@ -9608,6 +9610,14 @@ export function PrintersPage() {
             <ArrowDown className="w-4 h-4 text-white" />
           )}
         </button>
+        <RouterLink
+          to="/printer-locations"
+          className="h-8 shrink-0 px-2 rounded-lg border bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center justify-center"
+          title={t('printers.locations.title')}
+          aria-label={t('printers.locations.title')}
+        >
+          <Box className="w-4 h-4 text-bambu-green" />
+        </RouterLink>
       </div>
 
       {/* Page view toggle: Cards / Cam Wall */}

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BdV2HXT0.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-qR-OFMS4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-1LJAWv55.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-CFtTE_hL.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-CaVfzyDI.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-MteGyJn2.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-DXlN_HIi.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-7L3DC4So.js"></script>
+    <script type="module" crossorigin src="/assets/index-CFtTE_hL.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-MteGyJn2.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-CaVfzyDI.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff