Explorar el Código

feat(projects): per-file print progress and complete-sets tracking (#1897)

Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.

Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.

Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.

Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.

Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
maziggy hace 1 mes
padre
commit
af7874546a

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 13 - 0
backend/app/api/routes/library.py

@@ -2659,6 +2659,17 @@ async def add_files_to_queue(
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
     files = {f.id: f for f in result.scalars().all()}
 
 
+    # Project attribution (#1897): a file queued from a project-linked folder
+    # inherits that project, so the resulting archive counts toward the
+    # project's progress. A file's own project link wins over its folder's.
+    folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
+    folder_projects: dict[int, int | None] = {}
+    if folder_ids:
+        folder_result = await db.execute(
+            select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
+        )
+        folder_projects = dict(folder_result.all())
+
     # Get max position for queue ordering
     # Get max position for queue ordering
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
     max_position = pos_result.scalar() or 0
@@ -2696,6 +2707,8 @@ async def add_files_to_queue(
             queue_item = PrintQueueItem(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 printer_id=None,  # Unassigned
                 library_file_id=file_id,
                 library_file_id=file_id,
+                project_id=lib_file.project_id
+                or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 position=max_position,
                 status="pending",
                 status="pending",
             )
             )

+ 90 - 0
backend/app/api/routes/projects.py

@@ -34,6 +34,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectChildPreview,
     ProjectCreate,
     ProjectCreate,
+    ProjectFileProgress,
     ProjectImport,
     ProjectImport,
     ProjectListResponse,
     ProjectListResponse,
     ProjectResponse,
     ProjectResponse,
@@ -262,6 +263,7 @@ async def list_projects(
                 status=project.status,
                 status=project.status,
                 target_count=project.target_count,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 budget=project.budget,
                 tags=project.tags,
                 tags=project.tags,
                 due_date=project.due_date,
                 due_date=project.due_date,
@@ -304,6 +306,7 @@ async def create_project(
         color=data.color,
         color=data.color,
         target_count=data.target_count,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         notes=data.notes,
         tags=data.tags,
         tags=data.tags,
         due_date=data.due_date,
         due_date=data.due_date,
@@ -326,6 +329,7 @@ async def create_project(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,
@@ -374,6 +378,7 @@ async def list_templates(
                 status=project.status,
                 status=project.status,
                 target_count=project.target_count,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 budget=project.budget,
                 tags=project.tags,
                 tags=project.tags,
                 due_date=project.due_date,
                 due_date=project.due_date,
@@ -415,6 +420,7 @@ async def create_project_from_template(
         color=template.color,
         color=template.color,
         target_count=template.target_count,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         notes=template.notes,
         tags=template.tags,
         tags=template.tags,
         priority=template.priority,
         priority=template.priority,
@@ -457,6 +463,7 @@ async def create_project_from_template(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,
@@ -542,6 +549,7 @@ async def get_project(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,
@@ -590,6 +598,10 @@ async def update_project(
         project.target_count = data.target_count
         project.target_count = data.target_count
     if data.target_parts_count is not None:
     if data.target_parts_count is not None:
         project.target_parts_count = data.target_parts_count
         project.target_parts_count = data.target_parts_count
+    # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
+    # alone (same #2536 semantics as tags/due_date below).
+    if "target_sets" in data.model_fields_set:
+        project.target_sets = data.target_sets
     if data.notes is not None:
     if data.notes is not None:
         project.notes = data.notes
         project.notes = data.notes
     # Sent-but-null clears the field; omitted leaves it alone. Guarding on
     # Sent-but-null clears the field; omitted leaves it alone. Guarding on
@@ -642,6 +654,7 @@ async def update_project(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,
@@ -740,6 +753,76 @@ async def list_project_queue(
     return items
     return items
 
 
 
 
+@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
+async def get_project_file_progress(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+):
+    """Completed-run counts per library file inside a project (#1897).
+
+    Counts completed ``PrintLogEntry`` rows (same source as the aggregate
+    project stats) of archives attributed to this project, and maps each run to
+    one of the project's library files — the files living in folders linked to
+    the project, the same set the project detail page renders.
+
+    A run is attributed to exactly one file, by the strongest available match:
+    1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
+    2. content hash (covers historical rows),
+    3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
+    Files with no completed runs are omitted — the frontend treats absence as 0.
+    """
+    result = await db.execute(select(Project.id).where(Project.id == project_id))
+    if result.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    files_result = await db.execute(
+        select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
+        .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
+    )
+    file_rows = files_result.all()
+    if not file_rows:
+        return []
+
+    # First match wins within each tier, so iteration order (file id) is stable
+    # when duplicates share a hash or filename.
+    by_id = {fid for fid, _, _ in file_rows}
+    by_hash: dict[str, int] = {}
+    by_name: dict[str, int] = {}
+    for fid, fhash, fname in file_rows:
+        if fhash and fhash not in by_hash:
+            by_hash[fhash] = fid
+        if fname not in by_name:
+            by_name[fname] = fid
+
+    runs_result = await db.execute(
+        select(
+            PrintArchive.library_file_id,
+            PrintArchive.content_hash,
+            PrintArchive.filename,
+            func.count(PrintLogEntry.id),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed")
+        .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
+    )
+
+    counts: dict[int, int] = {}
+    for lib_file_id, content_hash, filename, run_count in runs_result.all():
+        if lib_file_id in by_id:
+            fid = lib_file_id
+        elif content_hash and content_hash in by_hash:
+            fid = by_hash[content_hash]
+        elif filename in by_name:
+            fid = by_name[filename]
+        else:
+            continue
+        counts[fid] = counts.get(fid, 0) + run_count
+
+    return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
+
+
 @router.post("/{project_id}/add-archives")
 @router.post("/{project_id}/add-archives")
 async def add_archives_to_project(
 async def add_archives_to_project(
     project_id: int,
     project_id: int,
@@ -1402,6 +1485,7 @@ async def create_template_from_project(
         color=source.color,
         color=source.color,
         target_count=source.target_count,
         target_count=source.target_count,
         target_parts_count=source.target_parts_count,
         target_parts_count=source.target_parts_count,
+        target_sets=source.target_sets,
         notes=source.notes,
         notes=source.notes,
         tags=source.tags,
         tags=source.tags,
         priority=source.priority,
         priority=source.priority,
@@ -1444,6 +1528,7 @@ async def create_template_from_project(
         status=template.status,
         status=template.status,
         target_count=template.target_count,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         notes=template.notes,
         attachments=template.attachments,
         attachments=template.attachments,
         url=template.url,
         url=template.url,
@@ -1653,6 +1738,7 @@ async def export_project(
         "status": project.status,
         "status": project.status,
         "target_count": project.target_count,
         "target_count": project.target_count,
         "target_parts_count": project.target_parts_count,
         "target_parts_count": project.target_parts_count,
+        "target_sets": project.target_sets,
         "notes": project.notes,
         "notes": project.notes,
         "tags": project.tags,
         "tags": project.tags,
         "due_date": project.due_date.isoformat() if project.due_date else None,
         "due_date": project.due_date.isoformat() if project.due_date else None,
@@ -1704,6 +1790,7 @@ async def import_project(
         status=data.status,
         status=data.status,
         target_count=data.target_count,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         notes=data.notes,
         tags=data.tags,
         tags=data.tags,
         due_date=data.due_date,
         due_date=data.due_date,
@@ -1766,6 +1853,7 @@ async def import_project(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,
@@ -1829,6 +1917,7 @@ async def import_project_file(
         status=data.get("status", "active"),
         status=data.get("status", "active"),
         target_count=data.get("target_count"),
         target_count=data.get("target_count"),
         target_parts_count=data.get("target_parts_count"),
         target_parts_count=data.get("target_parts_count"),
+        target_sets=data.get("target_sets"),
         notes=data.get("notes"),
         notes=data.get("notes"),
         tags=data.get("tags"),
         tags=data.get("tags"),
         due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
         due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
@@ -1957,6 +2046,7 @@ async def import_project_file(
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
         url=project.url,
         url=project.url,

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

@@ -3787,6 +3787,17 @@ async def run_migrations(conn):
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)
 
 
+    # Migration: per-file print progress inside a project (#1897).
+    # - print_archives.library_file_id: which library file a queued run was
+    #   dispatched from; nullable, no FK constraint added to existing tables
+    #   (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
+    #   via the ORM on fresh installs and tolerates dangling ids by matching
+    #   hash/filename as fallback anyway).
+    # - projects.target_sets: optional copies-per-file target. INTEGER is
+    #   spelled identically on SQLite and Postgres — no dialect branch.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
+
 
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 6 - 0
backend/app/models/archive.py

@@ -12,6 +12,12 @@ class PrintArchive(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    # Which library file this run was dispatched from (#1897). Set by the queue
+    # scheduler when it archives a library-file print; older rows are matched by
+    # content_hash/filename instead. SET NULL so deleting a file keeps history.
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
 
 
     # File info
     # File info
     filename: Mapped[str] = mapped_column(String(255))
     filename: Mapped[str] = mapped_column(String(255))

+ 3 - 0
backend/app/models/project.py

@@ -30,6 +30,9 @@ class Project(Base):
     target_parts_count: Mapped[int | None] = mapped_column(
     target_parts_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
         Integer, nullable=True
     )  # Optional target number of parts/objects
     )  # Optional target number of parts/objects
+    # Optional copies-per-file target (#1897): every printable file in the
+    # project's linked folders should be printed this many times ("sets").
+    target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 13 - 0
backend/app/schemas/project.py

@@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
     color: str | None = None
     color: str | None = None
     target_count: int | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     notes: str | None = None
     tags: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
     due_date: datetime | None = None
@@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
     status: str | None = None  # active, completed, archived
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     notes: str | None = None
     tags: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
     due_date: datetime | None = None
@@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
     status: str
     status: str
     target_count: int | None
     target_count: int | None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     notes: str | None = None
     attachments: list | None = None
     attachments: list | None = None
     tags: str | None = None
     tags: str | None = None
@@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
         from_attributes = True
         from_attributes = True
 
 
 
 
+class ProjectFileProgress(BaseModel):
+    """Completed-run count for one library file inside a project (#1897)."""
+
+    file_id: int
+    completed_count: int
+
+
 class ArchivePreview(BaseModel):
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
     """Minimal archive data for project preview."""
 
 
@@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel):
     status: str
     status: str
     target_count: int | None
     target_count: int | None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897); the shared edit dialog needs it
     budget: float | None = None
     budget: float | None = None
     # The edit dialog is shared with the project detail page and seeds its fields
     # The edit dialog is shared with the project detail page and seeds its fields
     # from whichever project object it is handed, so the list payload has to carry
     # from whichever project object it is handed, so the list payload has to carry
@@ -276,6 +287,7 @@ class ProjectExport(BaseModel):
     status: str
     status: str
     target_count: int | None
     target_count: int | None
     target_parts_count: int | None
     target_parts_count: int | None
+    target_sets: int | None = None
     notes: str | None
     notes: str | None
     tags: str | None
     tags: str | None
     due_date: datetime | None
     due_date: datetime | None
@@ -294,6 +306,7 @@ class ProjectImport(BaseModel):
     status: str = "active"
     status: str = "active"
     target_count: int | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None
     notes: str | None = None
     notes: str | None = None
     tags: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
     due_date: datetime | None = None

+ 4 - 0
backend/app/services/archive.py

@@ -1143,6 +1143,7 @@ class ArchiveService:
         subtask_id: str | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         plate_id: int | None = None,
+        library_file_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
         """Archive a 3MF file with metadata.
 
 
@@ -1155,6 +1156,8 @@ class ArchiveService:
                 stored with UUID names)
                 stored with UUID names)
             project_id: Project to associate this archive with (optional, set when triggered
             project_id: Project to associate this archive with (optional, set when triggered
                 from the project view)
                 from the project view)
+            library_file_id: Library file this run was dispatched from (optional,
+                set by the queue scheduler — powers per-file project progress, #1897)
             subtask_id: MQTT-provided task identifier (optional). Used to match an
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
                 original row can be resumed instead of cancelled (#972).
@@ -1314,6 +1317,7 @@ class ArchiveService:
             extra_data=metadata,
             extra_data=metadata,
             created_by_id=created_by_id,
             created_by_id=created_by_id,
             project_id=project_id,
             project_id=project_id,
+            library_file_id=library_file_id,
             subtask_id=subtask_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
             plate_id=plate_id,
         )
         )

+ 1 - 0
backend/app/services/print_scheduler.py

@@ -3122,6 +3122,7 @@ class PrintScheduler:
                     original_filename=filename,
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
                     project_id=item.project_id,
+                    library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 )
                 if archive:
                 if archive:

+ 10 - 1
backend/tests/conftest.py

@@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
     test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
 
 
     async def override_get_db():
     async def override_get_db():
+        # Mirror production get_db (core/database.py): commit on success,
+        # rollback on error. Endpoints that rely on the request-scoped
+        # implicit commit (e.g. create_project, which only flushes) would
+        # otherwise silently lose their writes in tests (#1897).
         async with test_async_session() as session:
         async with test_async_session() as session:
-            yield session
+            try:
+                yield session
+                await session.commit()
+            except BaseException:
+                await session.rollback()
+                raise
 
 
     app.dependency_overrides[get_db] = override_get_db
     app.dependency_overrides[get_db] = override_get_db
 
 

+ 224 - 0
backend/tests/integration/test_projects_api.py

@@ -1393,3 +1393,227 @@ class TestProjectListEditableFields:
         result = response.json()
         result = response.json()
         assert result["tags"] is None
         assert result["tags"] is None
         assert result["due_date"] is None
         assert result["due_date"] is None
+
+
+class TestProjectFileProgress:
+    """Per-file print progress inside a project (#1897).
+
+    Covers GET /projects/{id}/file-progress (attribution: library_file_id →
+    content hash → filename, completed runs only, project-scoped), the
+    target_sets field round-trip, and the add-to-queue project inheritance
+    that feeds the attribution chain.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            defaults = {"name": f"Progress Project {_counter[0]}"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def folder_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_folder(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            _counter[0] += 1
+            defaults = {"name": f"ProgressFolder {_counter[0]}"}
+            defaults.update(kwargs)
+            folder = LibraryFolder(**defaults)
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create_folder
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "filename": f"plate_{counter}.gcode.3mf",
+                "file_path": f"library/plate_{counter}.gcode.3mf",
+                "file_size": 1024,
+                "file_type": "3mf",
+            }
+            defaults.update(kwargs)
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_counts_by_library_file_id(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs stamped with library_file_id count toward that file even when
+        the archive's filename differs (rename after dispatch)."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        file_a = await file_factory(folder_id=folder.id)
+        file_b = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        for _ in range(2):
+            await archive_factory(
+                printer.id,
+                project_id=project.id,
+                library_file_id=file_a.id,
+                filename="renamed_on_dispatch.gcode.3mf",
+            )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.status_code == 200
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {file_a.id: 2, file_b.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hash_and_filename_fallback(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Historical archives without library_file_id match by content hash,
+        then by filename."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
+        named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
+        printer = await printer_factory()
+
+        # Hash match despite a different filename
+        await archive_factory(
+            printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
+        )
+        # Filename match with no hash on either side
+        await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {hashed_file.id: 1, named_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_only_completed_runs_count(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Failed runs and never-printed archives do not advance the count."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
+        await archive_factory(
+            printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
+        )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {lib_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scoped_to_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs of the same file outside the project (no project / another
+        project) are excluded."""
+        project = await project_factory()
+        other_project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
+        await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_project_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/projects/999999/file-progress")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_sets_roundtrip(self, async_client: AsyncClient):
+        """target_sets survives create, update, and explicit-null clearing."""
+        create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
+        assert create.status_code == 200
+        project = create.json()
+        assert project["target_sets"] == 10
+
+        update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
+        assert update.status_code == 200, update.json()
+        assert update.json()["target_sets"] == 4
+
+        cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
+        assert cleared.json()["target_sets"] is None
+
+        untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
+        assert untouched.json()["target_sets"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_inherits_folder_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
+    ):
+        """Queueing a file from a project-linked folder attributes the queue
+        item (and thus the later archive) to that project; a root file stays
+        unattributed."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+
+        on_disk = tmp_path / "linked.gcode.3mf"
+        on_disk.write_bytes(b"fake sliced content")
+        linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
+
+        root_disk = tmp_path / "root.gcode.3mf"
+        root_disk.write_bytes(b"fake sliced content")
+        root_file = await file_factory(folder_id=None, file_path=str(root_disk))
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
+        )
+        assert response.status_code == 200
+        assert len(response.json()["added"]) == 2
+
+        result = await db_session.execute(
+            select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
+                PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
+            )
+        )
+        projects_by_file = dict(result.all())
+        assert projects_by_file[linked_file.id] == project.id
+        assert projects_by_file[root_file.id] is None

+ 10 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -111,7 +111,15 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
     scheduler = PrintScheduler()
     scheduler = PrintScheduler()
 
 
     async def archive_print(
     async def archive_print(
-        self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None, plate_id=None
+        self,
+        *,
+        printer_id,
+        source_file,
+        original_filename,
+        created_by_id=None,
+        project_id=None,
+        plate_id=None,
+        library_file_id=None,
     ):
     ):
         if archive_failure:
         if archive_failure:
             raise RuntimeError("archive copy failed")
             raise RuntimeError("archive copy failed")
@@ -132,6 +140,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
             print_time_seconds=120,
             print_time_seconds=120,
             status="completed",
             status="completed",
             project_id=project_id,
             project_id=project_id,
+            library_file_id=library_file_id,
             created_by_id=created_by_id,
             created_by_id=created_by_id,
         )
         )
         self.db.add(archive)
         self.db.add(archive)

+ 2 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -538,6 +538,8 @@ export const handlers = [
   http.get('/api/v1/obico/printer-status', () =>
   http.get('/api/v1/obico/printer-status', () =>
     HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
     HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
   ),
   ),
+  // Per-file project print progress (#1897) — empty means "no completed runs"
+  http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
   http.get('/api/v1/settings/check-ffmpeg', () =>
   http.get('/api/v1/settings/check-ffmpeg', () =>
     HttpResponse.json({ available: false, version: null })
     HttpResponse.json({ available: false, version: null })

+ 82 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -222,4 +222,86 @@ describe('ProjectDetailPage', () => {
       });
       });
     });
     });
   });
   });
+  describe('per-file print progress (#1897)', () => {
+    it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 10 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 3 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2
+      await waitFor(() => {
+        expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument();
+      });
+      expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument();
+
+      // Complete sets = min across printable files = 0
+      expect(screen.getByText('Complete Sets')).toBeInTheDocument();
+      expect(
+        screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets')
+      ).toBeInTheDocument();
+    });
+
+    it('counts a complete set once every printable file reached the target', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 2 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            // STL is not printable and must not drag the set count to 0
+            makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          // plate_1 overshot the target; capped at 2 for the set count
+          return HttpResponse.json([
+            { file_id: 5, completed_count: 3 },
+            { file_id: 6, completed_count: 2 },
+          ]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows a plain printed-count badge when no target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 4 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('4\u00d7')).toBeInTheDocument();
+      });
+      // No sets bar without a target
+      expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
+    });
+  });
 });
 });

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

@@ -911,6 +911,7 @@ export interface Project {
   status: string;  // active, completed, archived
   status: string;  // active, completed, archived
   target_count: number | null;  // Target number of plates/print jobs
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // Copies-per-file target (#1897)
   notes: string | null;
   notes: string | null;
   attachments: ProjectAttachment[] | null;
   attachments: ProjectAttachment[] | null;
   tags: string | null;
   tags: string | null;
@@ -936,6 +937,12 @@ export interface ProjectAttachment {
   uploaded_at: string;
   uploaded_at: string;
 }
 }
 
 
+// Completed-run count for one library file inside a project (#1897)
+export interface ProjectFileProgress {
+  file_id: number;
+  completed_count: number;
+}
+
 export interface ArchivePreview {
 export interface ArchivePreview {
   id: number;
   id: number;
   print_name: string | null;
   print_name: string | null;
@@ -953,6 +960,7 @@ export interface ProjectListItem {
   status: string;
   status: string;
   target_count: number | null;  // Target number of plates/print jobs
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // #1897 — the shared edit dialog seeds itself from this
   budget: number | null;
   budget: number | null;
   tags: string | null;  // #2536 — the shared edit dialog seeds itself from this
   tags: string | null;  // #2536 — the shared edit dialog seeds itself from this
   due_date: string | null;  // #2536
   due_date: string | null;  // #2536
@@ -975,6 +983,7 @@ export interface ProjectCreate {
   color?: string;
   color?: string;
   target_count?: number;
   target_count?: number;
   target_parts_count?: number;
   target_parts_count?: number;
+  target_sets?: number;
   notes?: string;
   notes?: string;
   tags?: string;
   tags?: string;
   due_date?: string;
   due_date?: string;
@@ -991,6 +1000,7 @@ export interface ProjectUpdate {
   status?: string;
   status?: string;
   target_count?: number;
   target_count?: number;
   target_parts_count?: number;
   target_parts_count?: number;
+  target_sets?: number | null;  // #1897 — explicit null clears the copies-per-file target
   notes?: string;
   notes?: string;
   tags?: string | null;  // #2536 — explicit null clears the tags
   tags?: string | null;  // #2536 — explicit null clears the tags
   due_date?: string | null;  // #2536 — explicit null clears the due date
   due_date?: string | null;  // #2536 — explicit null clears the due date
@@ -1062,6 +1072,7 @@ export interface ProjectExport {
   status: string;
   status: string;
   target_count: number | null;
   target_count: number | null;
   target_parts_count: number | null;
   target_parts_count: number | null;
+  target_sets: number | null;  // #1897
   notes: string | null;
   notes: string | null;
   tags: string | null;
   tags: string | null;
   due_date: string | null;
   due_date: string | null;
@@ -1078,6 +1089,7 @@ export interface ProjectImport {
   status?: string;
   status?: string;
   target_count?: number;
   target_count?: number;
   target_parts_count?: number;
   target_parts_count?: number;
+  target_sets?: number;  // #1897
   notes?: string;
   notes?: string;
   tags?: string;
   tags?: string;
   due_date?: string;
   due_date?: string;
@@ -5947,6 +5959,9 @@ export const api = {
     request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
     request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
   getProjectArchives: (id: number, limit = 100, offset = 0) =>
   getProjectArchives: (id: number, limit = 100, offset = 0) =>
     request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
     request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
+  // Completed-run counts per library file (#1897); files with 0 runs are omitted
+  getProjectFileProgress: (id: number) =>
+    request<ProjectFileProgress[]>(`/projects/${id}/file-progress`),
   addArchivesToProject: (projectId: number, archiveIds: number[]) =>
   addArchivesToProject: (projectId: number, archiveIds: number[]) =>
     request<{ message: string }>(`/projects/${projectId}/add-archives`, {
     request<{ message: string }>(`/projects/${projectId}/add-archives`, {
       method: 'POST',
       method: 'POST',

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

@@ -3784,6 +3784,9 @@ export default {
     targetParts: 'Ziel-Teile',
     targetParts: 'Ziel-Teile',
     targetPartsPlaceholder: 'z.B. 150',
     targetPartsPlaceholder: 'z.B. 150',
     targetPartsHelp: 'Benötigte Objekte insgesamt',
     targetPartsHelp: 'Benötigte Objekte insgesamt',
+    targetSets: 'Kopien pro Datei',
+    targetSetsPlaceholder: 'z. B. 10',
+    targetSetsHelp: 'Wie oft jede druckbare Datei gedruckt werden soll',
     tagsLabel: 'Tags (kommagetrennt)',
     tagsLabel: 'Tags (kommagetrennt)',
     tagsPlaceholder: 'z.B. voron, funktional, geschenk',
     tagsPlaceholder: 'z.B. voron, funktional, geschenk',
     dueDate: 'Fälligkeitsdatum',
     dueDate: 'Fälligkeitsdatum',
@@ -3859,6 +3862,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Platten-Fortschritt',
       platesProgress: 'Platten-Fortschritt',
       partsProgress: 'Teile-Fortschritt',
       partsProgress: 'Teile-Fortschritt',
+      setsProgress: 'Vollständige Sätze',
+      sets: 'Sätze',
+      setsHint: 'Ein Satz ist vollständig, wenn jede druckbare Datei diese Anzahl erreicht hat',
       printJobs: 'Druckaufträge',
       printJobs: 'Druckaufträge',
       parts: 'Teile',
       parts: 'Teile',
       percentComplete: '{{percent}}% abgeschlossen',
       percentComplete: '{{percent}}% abgeschlossen',
@@ -3897,6 +3903,8 @@ export default {
       forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
       forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
       fileCount: '{{count}} Datei(en)',
       fileCount: '{{count}} Datei(en)',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
+      printedCount: '{{count}}-mal gedruckt',
+      progressTooltip: '{{done}} von {{target}} abgeschlossenen Drucken',
       noFiles: 'Keine Dateien in diesem Ordner.',
       noFiles: 'Keine Dateien in diesem Ordner.',
     },
     },
     bom: {
     bom: {

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

@@ -3813,6 +3813,9 @@ export default {
     targetParts: 'Target Parts',
     targetParts: 'Target Parts',
     targetPartsPlaceholder: 'e.g., 150',
     targetPartsPlaceholder: 'e.g., 150',
     targetPartsHelp: 'Total objects needed',
     targetPartsHelp: 'Total objects needed',
+    targetSets: 'Copies per File',
+    targetSetsPlaceholder: 'e.g., 10',
+    targetSetsHelp: 'Times each printable file should be printed',
     tagsLabel: 'Tags (comma-separated)',
     tagsLabel: 'Tags (comma-separated)',
     tagsPlaceholder: 'e.g., voron, functional, gift',
     tagsPlaceholder: 'e.g., voron, functional, gift',
     dueDate: 'Due Date',
     dueDate: 'Due Date',
@@ -3888,6 +3891,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Plates Progress',
       platesProgress: 'Plates Progress',
       partsProgress: 'Parts Progress',
       partsProgress: 'Parts Progress',
+      setsProgress: 'Complete Sets',
+      sets: 'sets',
+      setsHint: 'A set is complete once every printable file has reached this count',
       printJobs: 'print jobs',
       printJobs: 'print jobs',
       parts: 'parts',
       parts: 'parts',
       percentComplete: '{{percent}}% complete',
       percentComplete: '{{percent}}% complete',
@@ -3926,6 +3932,8 @@ export default {
       forQuickAccess: 'to this project for quick access.',
       forQuickAccess: 'to this project for quick access.',
       fileCount: '{{count}} file(s)',
       fileCount: '{{count}} file(s)',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
+      printedCount: 'Printed {{count}} time(s)',
+      progressTooltip: '{{done}} of {{target}} completed prints',
       noFiles: 'No files in this folder.',
       noFiles: 'No files in this folder.',
     },
     },
     bom: {
     bom: {

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

@@ -3787,6 +3787,9 @@ export default {
     targetParts: 'Piezas objetivo',
     targetParts: 'Piezas objetivo',
     targetPartsPlaceholder: 'p. ej., 150',
     targetPartsPlaceholder: 'p. ej., 150',
     targetPartsHelp: 'Total de objetos necesarios',
     targetPartsHelp: 'Total de objetos necesarios',
+    targetSets: 'Copias por archivo',
+    targetSetsPlaceholder: 'p. ej., 10',
+    targetSetsHelp: 'Veces que debe imprimirse cada archivo imprimible',
     tagsLabel: 'Etiquetas (separadas por comas)',
     tagsLabel: 'Etiquetas (separadas por comas)',
     tagsPlaceholder: 'p. ej., voron, funcional, regalo',
     tagsPlaceholder: 'p. ej., voron, funcional, regalo',
     dueDate: 'Fecha límite',
     dueDate: 'Fecha límite',
@@ -3862,6 +3865,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Progreso de camas',
       platesProgress: 'Progreso de camas',
       partsProgress: 'Progreso de piezas',
       partsProgress: 'Progreso de piezas',
+      setsProgress: 'Juegos completos',
+      sets: 'juegos',
+      setsHint: 'Un juego está completo cuando cada archivo imprimible alcanza este número',
       printJobs: 'trabajos de impresión',
       printJobs: 'trabajos de impresión',
       parts: 'piezas',
       parts: 'piezas',
       percentComplete: '{{percent}}% completado',
       percentComplete: '{{percent}}% completado',
@@ -3900,6 +3906,8 @@ export default {
       forQuickAccess: 'a este proyecto para un acceso rápido.',
       forQuickAccess: 'a este proyecto para un acceso rápido.',
       fileCount: '{{count}} archivo(s)',
       fileCount: '{{count}} archivo(s)',
       empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.',
       empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.',
+      printedCount: 'Impreso {{count}} veces',
+      progressTooltip: '{{done}} de {{target}} impresiones completadas',
       noFiles: 'No hay archivos en esta carpeta.',
       noFiles: 'No hay archivos en esta carpeta.',
     },
     },
     bom: {
     bom: {

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

@@ -3773,6 +3773,9 @@ export default {
     targetParts: 'Pièces cibles',
     targetParts: 'Pièces cibles',
     targetPartsPlaceholder: 'ex: 150',
     targetPartsPlaceholder: 'ex: 150',
     targetPartsHelp: 'Nombre total d\'objets',
     targetPartsHelp: 'Nombre total d\'objets',
+    targetSets: 'Copies par fichier',
+    targetSetsPlaceholder: 'ex. 10',
+    targetSetsHelp: 'Nombre d\'impressions de chaque fichier imprimable',
     tagsLabel: 'Tags (séparés par virgules)',
     tagsLabel: 'Tags (séparés par virgules)',
     tagsPlaceholder: 'ex: voron, cadeau',
     tagsPlaceholder: 'ex: voron, cadeau',
     dueDate: 'Échéance',
     dueDate: 'Échéance',
@@ -3848,6 +3851,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Progression Plateaux',
       platesProgress: 'Progression Plateaux',
       partsProgress: 'Progression Pièces',
       partsProgress: 'Progression Pièces',
+      setsProgress: 'Jeux complets',
+      sets: 'jeux',
+      setsHint: 'Un jeu est complet quand chaque fichier imprimable atteint ce nombre',
       printJobs: 'jobs d\'impression',
       printJobs: 'jobs d\'impression',
       parts: 'pièces',
       parts: 'pièces',
       percentComplete: '{{percent}}% terminé',
       percentComplete: '{{percent}}% terminé',
@@ -3886,6 +3892,8 @@ export default {
       forQuickAccess: 'pour un accès rapide.',
       forQuickAccess: 'pour un accès rapide.',
       fileCount: '{{count}} fichier(s)',
       fileCount: '{{count}} fichier(s)',
       empty: 'Aucun dossier lié.',
       empty: 'Aucun dossier lié.',
+      printedCount: 'Imprimé {{count}} fois',
+      progressTooltip: '{{done}} sur {{target}} impressions terminées',
       noFiles: 'Aucun fichier dans ce dossier.',
       noFiles: 'Aucun fichier dans ce dossier.',
     },
     },
     bom: {
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: 'Parti target',
     targetParts: 'Parti target',
     targetPartsPlaceholder: 'es., 150',
     targetPartsPlaceholder: 'es., 150',
     targetPartsHelp: 'Totale oggetti necessari',
     targetPartsHelp: 'Totale oggetti necessari',
+    targetSets: 'Copie per file',
+    targetSetsPlaceholder: 'es. 10',
+    targetSetsHelp: 'Quante volte stampare ogni file stampabile',
     tagsLabel: 'Tag (separati da virgola)',
     tagsLabel: 'Tag (separati da virgola)',
     tagsPlaceholder: 'es., voron, funzionale, regalo',
     tagsPlaceholder: 'es., voron, funzionale, regalo',
     dueDate: 'Data scadenza',
     dueDate: 'Data scadenza',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Avanzamento piatti',
       platesProgress: 'Avanzamento piatti',
       partsProgress: 'Avanzamento parti',
       partsProgress: 'Avanzamento parti',
+      setsProgress: 'Set completi',
+      sets: 'set',
+      setsHint: 'Un set è completo quando ogni file stampabile raggiunge questo numero',
       printJobs: 'job di stampa',
       printJobs: 'job di stampa',
       parts: 'parti',
       parts: 'parti',
       percentComplete: '{{percent}}% completato',
       percentComplete: '{{percent}}% completato',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: 'a questo progetto per accesso rapido.',
       forQuickAccess: 'a questo progetto per accesso rapido.',
       fileCount: '{{count}} file',
       fileCount: '{{count}} file',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
+      printedCount: 'Stampato {{count}} volte',
+      progressTooltip: '{{done}} di {{target}} stampe completate',
       noFiles: 'Nessun file in questa cartella.',
       noFiles: 'Nessun file in questa cartella.',
     },
     },
     bom: {
     bom: {

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

@@ -3784,6 +3784,9 @@ export default {
     targetParts: '目標パーツ数',
     targetParts: '目標パーツ数',
     targetPartsPlaceholder: '例: 50',
     targetPartsPlaceholder: '例: 50',
     targetPartsHelp: '必要なオブジェクトの総数',
     targetPartsHelp: '必要なオブジェクトの総数',
+    targetSets: 'ファイルごとの部数',
+    targetSetsPlaceholder: '例: 10',
+    targetSetsHelp: '各印刷可能ファイルを印刷する回数',
     tagsLabel: 'タグ(カンマ区切り)',
     tagsLabel: 'タグ(カンマ区切り)',
     tagsPlaceholder: 'カンマ区切りのタグ',
     tagsPlaceholder: 'カンマ区切りのタグ',
     dueDate: '期限',
     dueDate: '期限',
@@ -3859,6 +3862,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'プレート進捗',
       platesProgress: 'プレート進捗',
       partsProgress: 'パーツ進捗',
       partsProgress: 'パーツ進捗',
+      setsProgress: '完成セット',
+      sets: 'セット',
+      setsHint: 'すべての印刷可能ファイルがこの回数に達するとセットが完成します',
       printJobs: '印刷ジョブ',
       printJobs: '印刷ジョブ',
       parts: 'パーツ',
       parts: 'パーツ',
       percentComplete: '{{percent}}% 完了',
       percentComplete: '{{percent}}% 完了',
@@ -3897,6 +3903,8 @@ export default {
       forQuickAccess: 'してクイックアクセスできるようにします。',
       forQuickAccess: 'してクイックアクセスできるようにします。',
       fileCount: '{{count}}ファイル',
       fileCount: '{{count}}ファイル',
       empty: '<空>',
       empty: '<空>',
+      printedCount: '{{count}}回印刷済み',
+      progressTooltip: '{{target}}回中{{done}}回の印刷が完了',
       noFiles: 'このフォルダにファイルはありません。',
       noFiles: 'このフォルダにファイルはありません。',
     },
     },
     bom: {
     bom: {

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

@@ -3592,6 +3592,9 @@ export default {
     targetParts: '목표 부품',
     targetParts: '목표 부품',
     targetPartsPlaceholder: '예: 150',
     targetPartsPlaceholder: '예: 150',
     targetPartsHelp: '필요한 총 개체 수',
     targetPartsHelp: '필요한 총 개체 수',
+    targetSets: '파일당 복사본 수',
+    targetSetsPlaceholder: '예: 10',
+    targetSetsHelp: '각 인쇄 가능한 파일을 인쇄할 횟수',
     tagsLabel: '태그 (쉼표로 구분)',
     tagsLabel: '태그 (쉼표로 구분)',
     tagsPlaceholder: '예: voron, 기능성, 선물',
     tagsPlaceholder: '예: voron, 기능성, 선물',
     dueDate: '마감일',
     dueDate: '마감일',
@@ -3660,6 +3663,9 @@ export default {
     progress: {
     progress: {
       platesProgress: '플레이트 진행률',
       platesProgress: '플레이트 진행률',
       partsProgress: '부품 진행률',
       partsProgress: '부품 진행률',
+      setsProgress: '완성된 세트',
+      sets: '세트',
+      setsHint: '모든 인쇄 가능한 파일이 이 횟수에 도달하면 세트가 완성됩니다',
       printJobs: '인쇄 작업',
       printJobs: '인쇄 작업',
       parts: '부품',
       parts: '부품',
       percentComplete: '{{percent}}% 완료',
       percentComplete: '{{percent}}% 완료',
@@ -3698,6 +3704,8 @@ export default {
       forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.',
       forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.',
       fileCount: '{{count}}개 파일',
       fileCount: '{{count}}개 파일',
       empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.',
       empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.',
+      printedCount: '{{count}}회 인쇄됨',
+      progressTooltip: '{{target}}회 중 {{done}}회 인쇄 완료',
       noFiles: '이 폴더에 파일이 없습니다.'
       noFiles: '이 폴더에 파일이 없습니다.'
     },
     },
     bom: {
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: 'Peças Alvo',
     targetParts: 'Peças Alvo',
     targetPartsPlaceholder: 'ex., 150',
     targetPartsPlaceholder: 'ex., 150',
     targetPartsHelp: 'Total de objetos necessários',
     targetPartsHelp: 'Total de objetos necessários',
+    targetSets: 'Cópias por arquivo',
+    targetSetsPlaceholder: 'ex.: 10',
+    targetSetsHelp: 'Quantas vezes cada arquivo imprimível deve ser impresso',
     tagsLabel: 'Tags (separadas por vírgula)',
     tagsLabel: 'Tags (separadas por vírgula)',
     tagsPlaceholder: 'ex., voron, funcional, presente',
     tagsPlaceholder: 'ex., voron, funcional, presente',
     dueDate: 'Data de Vencimento',
     dueDate: 'Data de Vencimento',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Progresso das Placas',
       platesProgress: 'Progresso das Placas',
       partsProgress: 'Progresso das Peças',
       partsProgress: 'Progresso das Peças',
+      setsProgress: 'Conjuntos completos',
+      sets: 'conjuntos',
+      setsHint: 'Um conjunto está completo quando cada arquivo imprimível atinge esse número',
       printJobs: 'Trabalhos de Impressão',
       printJobs: 'Trabalhos de Impressão',
       parts: 'Peças',
       parts: 'Peças',
       percentComplete: '{{percent}}% concluído',
       percentComplete: '{{percent}}% concluído',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: 'a este projeto para acesso rápido.',
       forQuickAccess: 'a este projeto para acesso rápido.',
       fileCount: '{{count}} arquivo(s)',
       fileCount: '{{count}} arquivo(s)',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+      printedCount: 'Impresso {{count}} vez(es)',
+      progressTooltip: '{{done}} de {{target}} impressões concluídas',
       noFiles: 'Nenhum arquivo nesta pasta.',
       noFiles: 'Nenhum arquivo nesta pasta.',
     },
     },
     bom: {
     bom: {

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

@@ -3584,6 +3584,9 @@ export default {
     targetParts: "План по деталям",
     targetParts: "План по деталям",
     targetPartsPlaceholder: "например, 150",
     targetPartsPlaceholder: "например, 150",
     targetPartsHelp: "Общее требуемое количество объектов",
     targetPartsHelp: "Общее требуемое количество объектов",
+    targetSets: "Копий на файл",
+    targetSetsPlaceholder: "напр. 10",
+    targetSetsHelp: "Сколько раз нужно напечатать каждый печатаемый файл",
     tagsLabel: "Метки (через запятую)",
     tagsLabel: "Метки (через запятую)",
     tagsPlaceholder: "например, voron, функциональное, подарок",
     tagsPlaceholder: "например, voron, функциональное, подарок",
     dueDate: "Срок",
     dueDate: "Срок",
@@ -3652,6 +3655,9 @@ export default {
     progress: {
     progress: {
       platesProgress: "Выполнение по пластинам",
       platesProgress: "Выполнение по пластинам",
       partsProgress: "Выполнение по деталям",
       partsProgress: "Выполнение по деталям",
+      setsProgress: "Полные комплекты",
+      sets: "комплектов",
+      setsHint: "Комплект готов, когда каждый печатаемый файл достиг этого количества",
       printJobs: "заданий печати",
       printJobs: "заданий печати",
       parts: "деталей",
       parts: "деталей",
       percentComplete: "Выполнено {{percent}}%",
       percentComplete: "Выполнено {{percent}}%",
@@ -3690,6 +3696,8 @@ export default {
       forQuickAccess: "с этим проектом для быстрого доступа.",
       forQuickAccess: "с этим проектом для быстрого доступа.",
       fileCount: "Файлов: {{count}}",
       fileCount: "Файлов: {{count}}",
       empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.",
       empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.",
+      printedCount: "Напечатано {{count}} раз",
+      progressTooltip: "{{done}} из {{target}} завершённых печатей",
       noFiles: "В этой папке нет файлов.",
       noFiles: "В этой папке нет файлов.",
     },
     },
     bom: {
     bom: {

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

@@ -3779,6 +3779,9 @@ export default {
     targetParts: 'Hedef Parçalar',
     targetParts: 'Hedef Parçalar',
     targetPartsPlaceholder: 'örn., 150',
     targetPartsPlaceholder: 'örn., 150',
     targetPartsHelp: 'Gereken toplam nesne',
     targetPartsHelp: 'Gereken toplam nesne',
+    targetSets: 'Dosya başına kopya',
+    targetSetsPlaceholder: 'örn. 10',
+    targetSetsHelp: 'Her yazdırılabilir dosyanın kaç kez yazdırılacağı',
     tagsLabel: 'Etiketler (virgülle ayrılmış)',
     tagsLabel: 'Etiketler (virgülle ayrılmış)',
     tagsPlaceholder: 'örn., voron, fonksiyonel, hediye',
     tagsPlaceholder: 'örn., voron, fonksiyonel, hediye',
     dueDate: 'Son Tarih',
     dueDate: 'Son Tarih',
@@ -3849,6 +3852,9 @@ export default {
     progress: {
     progress: {
       platesProgress: 'Plaka İlerlemesi',
       platesProgress: 'Plaka İlerlemesi',
       partsProgress: 'Parça İlerlemesi',
       partsProgress: 'Parça İlerlemesi',
+      setsProgress: 'Tamamlanan setler',
+      sets: 'set',
+      setsHint: 'Her yazdırılabilir dosya bu sayıya ulaştığında bir set tamamlanır',
       printJobs: 'baskı işi',
       printJobs: 'baskı işi',
       parts: 'parça',
       parts: 'parça',
       percentComplete: '%{{percent}} tamamlandı',
       percentComplete: '%{{percent}} tamamlandı',
@@ -3887,6 +3893,8 @@ export default {
       forQuickAccess: 'hızlı erişim için bu projeye.',
       forQuickAccess: 'hızlı erişim için bu projeye.',
       fileCount: '{{count}} dosya',
       fileCount: '{{count}} dosya',
       empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.',
       empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.',
+      printedCount: '{{count}} kez yazdırıldı',
+      progressTooltip: '{{target}} yazdırmadan {{done}} tanesi tamamlandı',
       noFiles: 'Bu klasörde dosya yok.',
       noFiles: 'Bu klasörde dosya yok.',
     },
     },
     bom: {
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: '目标零件数',
     targetParts: '目标零件数',
     targetPartsPlaceholder: '例如:150',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件总数',
     targetPartsHelp: '所需零件总数',
+    targetSets: '每个文件的份数',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每个可打印文件需要打印的次数',
     tagsLabel: '标签(逗号分隔)',
     tagsLabel: '标签(逗号分隔)',
     tagsPlaceholder: '例如:voron、功能件、礼物',
     tagsPlaceholder: '例如:voron、功能件、礼物',
     dueDate: '截止日期',
     dueDate: '截止日期',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
     progress: {
       platesProgress: '板进度',
       platesProgress: '板进度',
       partsProgress: '零件进度',
       partsProgress: '零件进度',
+      setsProgress: '完整套数',
+      sets: '套',
+      setsHint: '当每个可打印文件都达到此次数时,即完成一套',
       printJobs: '打印任务',
       printJobs: '打印任务',
       parts: '零件',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
       percentComplete: '{{percent}}% 完成',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: '到此项目以便快速访问。',
       forQuickAccess: '到此项目以便快速访问。',
       fileCount: '{{count}} 个文件',
       fileCount: '{{count}} 个文件',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
+      printedCount: '已打印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次打印',
       noFiles: '此文件夹中没有文件。',
       noFiles: '此文件夹中没有文件。',
     },
     },
     bom: {
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: '目標零件數',
     targetParts: '目標零件數',
     targetPartsPlaceholder: '例如:150',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件總數',
     targetPartsHelp: '所需零件總數',
+    targetSets: '每個檔案的份數',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每個可列印檔案需要列印的次數',
     tagsLabel: '標籤(逗號分隔)',
     tagsLabel: '標籤(逗號分隔)',
     tagsPlaceholder: '例如:voron、功能件、禮物',
     tagsPlaceholder: '例如:voron、功能件、禮物',
     dueDate: '截止日期',
     dueDate: '截止日期',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
     progress: {
       platesProgress: '板進度',
       platesProgress: '板進度',
       partsProgress: '零件進度',
       partsProgress: '零件進度',
+      setsProgress: '完整套數',
+      sets: '套',
+      setsHint: '當每個可列印檔案都達到此次數時,即完成一套',
       printJobs: '列印任務',
       printJobs: '列印任務',
       parts: '零件',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
       percentComplete: '{{percent}}% 完成',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: '到此項目以便快速存取。',
       forQuickAccess: '到此項目以便快速存取。',
       fileCount: '{{count}} 個檔案',
       fileCount: '{{count}} 個檔案',
       empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',
       empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',
+      printedCount: '已列印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次列印',
       noFiles: '此資料夾中沒有檔案。',
       noFiles: '此資料夾中沒有檔案。',
     },
     },
     bom: {
     bom: {

+ 80 - 1
frontend/src/pages/ProjectDetailPage.tsx

@@ -268,6 +268,29 @@ export function ProjectDetailPage() {
     return map;
     return map;
   }, [allProjectFiles]);
   }, [allProjectFiles]);
 
 
+  // Per-file completed-run counts (#1897); a file absent from the response has 0
+  const { data: fileProgress } = useQuery({
+    queryKey: ['project-file-progress', projectId],
+    queryFn: () => api.getProjectFileProgress(projectId),
+    enabled: projectId > 0,
+  });
+
+  const progressByFileId = useMemo(() => {
+    const map = new Map<number, number>();
+    for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count);
+    return map;
+  }, [fileProgress]);
+
+  // Complete sets (#1897): the number of finished assemblies — the minimum
+  // completed count across the project's printable files, capped at the target.
+  const completeSets = useMemo(() => {
+    const target = project?.target_sets;
+    if (!target || !allProjectFiles) return null;
+    const printable = allProjectFiles.filter((f) => isSlicedFilename(f.filename));
+    if (printable.length === 0) return null;
+    return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
+  }, [project?.target_sets, allProjectFiles, progressByFileId]);
+
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
 
@@ -536,7 +559,7 @@ export function ProjectDetailPage() {
       </div>
       </div>
 
 
       {/* Progress bars (if targets set) */}
       {/* Progress bars (if targets set) */}
-      {(project.target_count || project.target_parts_count) && (
+      {(project.target_count || project.target_parts_count || project.target_sets) && (
         <Card>
         <Card>
           <CardContent className="p-4 space-y-4">
           <CardContent className="p-4 space-y-4">
             {/* Plates progress */}
             {/* Plates progress */}
@@ -599,6 +622,27 @@ export function ProjectDetailPage() {
                 </div>
                 </div>
               </div>
               </div>
             )}
             )}
+            {/* Complete sets progress (#1897): min per-file completed count */}
+            {project.target_sets ? (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.progress.setsProgress')}</span>
+                  <span className="text-sm font-medium text-white">
+                    {completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')}
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(((completeSets ?? 0) / project.target_sets) * 100, 100)}%`,
+                      backgroundColor: (completeSets ?? 0) >= project.target_sets ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <p className="text-xs text-bambu-gray/70 mt-1">{t('projectDetail.progress.setsHint')}</p>
+              </div>
+            ) : null}
           </CardContent>
           </CardContent>
         </Card>
         </Card>
       )}
       )}
@@ -953,6 +997,41 @@ export function ProjectDetailPage() {
                                 </span>
                                 </span>
                               </div>
                               </div>
 
 
+                              {/* Per-file print progress (#1897) */}
+                              {printable && (() => {
+                                const done = progressByFileId.get(file.id) ?? 0;
+                                const target = project.target_sets;
+                                if (!target) {
+                                  // No copies-per-file target — show a plain printed-count badge
+                                  return done > 0 ? (
+                                    <span
+                                      className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
+                                      title={t('projectDetail.files.printedCount', { count: done })}
+                                    >
+                                      {done}×
+                                    </span>
+                                  ) : null;
+                                }
+                                const pct = Math.min((done / target) * 100, 100);
+                                const textColor =
+                                  done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
+                                const barColor =
+                                  done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
+                                return (
+                                  <div
+                                    className="shrink-0 w-20"
+                                    title={t('projectDetail.files.progressTooltip', { done, target })}
+                                  >
+                                    <p className={`text-xs font-medium text-right ${textColor}`}>
+                                      {done} / {target}
+                                    </p>
+                                    <div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
+                                      <div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
+                                    </div>
+                                  </div>
+                                );
+                              })()}
+
                               {/* Print actions for sliced files */}
                               {/* Print actions for sliced files */}
                               {printable && (
                               {printable && (
                                 <div className="flex items-center gap-1 shrink-0">
                                 <div className="flex items-center gap-1 shrink-0">

+ 19 - 0
frontend/src/pages/ProjectsPage.tsx

@@ -61,6 +61,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
   const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
+  const [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [status, setStatus] = useState(project?.status || 'active');
   const [tags, setTags] = useState(project?.tags || '');
   const [tags, setTags] = useState(project?.tags || '');
   const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
   const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
@@ -120,6 +121,8 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       color,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
       target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
       target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
+      // Null clears the copies-per-file target on edit (#1897); undefined omits on create.
+      target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined),
       // Null clears the stored value on edit; undefined omits the key on create.
       // Null clears the stored value on edit; undefined omits the key on create.
       // Sending undefined on edit would make an emptied field un-clearable.
       // Sending undefined on edit would make an emptied field un-clearable.
       tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
       tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
@@ -299,6 +302,22 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             </div>
             </div>
           </div>
           </div>
 
 
+          {/* Copies-per-file target (#1897) */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.targetSets')}
+            </label>
+            <input
+              type="number"
+              value={targetSets}
+              onChange={(e) => setTargetSets(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder={t('projects.targetSetsPlaceholder')}
+              min="1"
+            />
+            <p className="text-xs text-bambu-gray mt-1">{t('projects.targetSetsHelp')}</p>
+          </div>
+
           {/* Tags */}
           {/* Tags */}
           <div>
           <div>
             <label className="block text-sm font-medium text-white mb-1">
             <label className="block text-sm font-medium text-white mb-1">

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DLc2EliX.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CO7zRvej.js"></script>
+    <script type="module" crossorigin src="/assets/index-DLc2EliX.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   </head>
   <body>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio