Browse Source

- Add print quantity tracking for project progress

  - Track number of items per print job (default: 1)
  - Project stats now show total items vs print jobs
  - Progress bar counts items toward target, not just archives
  - "Items Printed" field in archive edit modal

  Backend:
  - Added quantity field to PrintArchive model
  - Database migration for quantity column
  - Updated project stats to use SUM(quantity)
  - Updated backup/restore to include quantity
  - Updated CSV/Excel export with quantity field

  Frontend:
  - Added quantity input to EditArchiveModal
  - Updated ProjectsPage to display total_items
  - Updated ProjectDetailPage stats
  - Fixed project delete not refreshing the list
maziggy 4 months ago
parent
commit
ea0bf5f932

+ 6 - 0
CHANGELOG.md

@@ -42,6 +42,12 @@ All notable changes to Bambuddy will be documented in this file.
   - Loading indicator shows while re-read is in progress
   - Loading indicator shows while re-read is in progress
   - Automatically tracks printer status to clear indicator when complete
   - Automatically tracks printer status to clear indicator when complete
   - Menu hidden when printer is busy (printing)
   - Menu hidden when printer is busy (printing)
+- **Print quantity tracking** - Track number of items per print job for project progress:
+  - Set "Items Printed" quantity when editing archived prints
+  - Project stats now show total items vs print jobs
+  - Progress bar tracks items toward target count
+  - Useful for batch printing (e.g., 10 copies in one print = 10 items)
+  - Default quantity of 1 for backwards compatibility
 
 
 ### Changed
 ### Changed
 - **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
 - **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons

+ 1 - 0
README.md

@@ -75,6 +75,7 @@
 ### 📁 Projects
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Group related prints (e.g., "Voron Build")
 - Track progress with target counts
 - Track progress with target counts
+- Quantity tracking for batch prints
 - Color-coded project badges
 - Color-coded project badges
 - Assign archives via context menu
 - Assign archives via context menu
 
 

+ 3 - 0
backend/app/api/routes/archives.py

@@ -89,6 +89,9 @@ def archive_to_response(
         "cost": archive.cost,
         "cost": archive.cost,
         "photos": archive.photos,
         "photos": archive.photos,
         "failure_reason": archive.failure_reason,
         "failure_reason": archive.failure_reason,
+        "quantity": archive.quantity,
+        "energy_kwh": archive.energy_kwh,
+        "energy_cost": archive.energy_cost,
         "created_at": archive.created_at,
         "created_at": archive.created_at,
     }
     }
 
 

+ 28 - 14
backend/app/api/routes/projects.py

@@ -39,21 +39,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
 
 
 async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
 async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
     """Compute statistics for a project."""
     """Compute statistics for a project."""
-    # Count total archives
+    # Count total archives (distinct print jobs)
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
     total_archives = total_result.scalar() or 0
     total_archives = total_result.scalar() or 0
 
 
-    # Count completed archives
+    # Sum total items (using quantity field)
+    total_items_result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
+    )
+    total_items = total_items_result.scalar() or 0
+
+    # Sum completed items (using quantity field)
     completed_result = await db.execute(
     completed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id, PrintArchive.status == "completed"
             PrintArchive.project_id == project_id, PrintArchive.status == "completed"
         )
         )
     )
     )
     completed_prints = completed_result.scalar() or 0
     completed_prints = completed_result.scalar() or 0
 
 
-    # Count failed archives
+    # Sum failed items (using quantity field)
     failed_result = await db.execute(
     failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
+        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
             PrintArchive.project_id == project_id, PrintArchive.status == "failed"
             PrintArchive.project_id == project_id, PrintArchive.status == "failed"
         )
         )
     )
     )
@@ -107,8 +113,9 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
 
 
     return ProjectStats(
     return ProjectStats(
         total_archives=total_archives,
         total_archives=total_archives,
-        completed_prints=completed_prints,
-        failed_prints=failed_prints,
+        total_items=int(total_items),
+        completed_prints=int(completed_prints),
+        failed_prints=int(failed_prints),
         queued_prints=queued_prints,
         queued_prints=queued_prints,
         in_progress_prints=in_progress_prints,
         in_progress_prints=in_progress_prints,
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
@@ -141,12 +148,18 @@ async def list_projects(
     # Compute quick stats for each project
     # Compute quick stats for each project
     response = []
     response = []
     for project in projects:
     for project in projects:
-        # Get archive count
+        # Get archive count (number of print jobs)
         archive_count_result = await db.execute(
         archive_count_result = await db.execute(
             select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
             select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
         )
         )
         archive_count = archive_count_result.scalar() or 0
         archive_count = archive_count_result.scalar() or 0
 
 
+        # Get total items (sum of quantities)
+        total_items_result = await db.execute(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
+        )
+        total_items = int(total_items_result.scalar() or 0)
+
         # Get queue count
         # Get queue count
         queue_count_result = await db.execute(
         queue_count_result = await db.execute(
             select(func.count(PrintQueueItem.id)).where(
             select(func.count(PrintQueueItem.id)).where(
@@ -156,14 +169,14 @@ async def list_projects(
         )
         )
         queue_count = queue_count_result.scalar() or 0
         queue_count = queue_count_result.scalar() or 0
 
 
-        # Get completed count for progress
+        # Get completed count for progress (sum of quantities)
         completed_result = await db.execute(
         completed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == project.id,
                 PrintArchive.project_id == project.id,
                 PrintArchive.status == "completed",
                 PrintArchive.status == "completed",
             )
             )
         )
         )
-        completed_count = completed_result.scalar() or 0
+        completed_count = int(completed_result.scalar() or 0)
 
 
         progress_percent = None
         progress_percent = None
         if project.target_count and project.target_count > 0:
         if project.target_count and project.target_count > 0:
@@ -199,6 +212,7 @@ async def list_projects(
                 target_count=project.target_count,
                 target_count=project.target_count,
                 created_at=project.created_at,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 archive_count=archive_count,
+                total_items=total_items,
                 queue_count=queue_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 progress_percent=progress_percent,
                 archives=archive_previews,
                 archives=archive_previews,
@@ -392,9 +406,9 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
 
 
     previews = []
     previews = []
     for child in children:
     for child in children:
-        # Get completed count for progress
+        # Get completed count for progress (sum of quantities)
         completed_result = await db.execute(
         completed_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
+            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == child.id,
                 PrintArchive.project_id == child.id,
                 PrintArchive.status == "completed",
                 PrintArchive.status == "completed",
             )
             )
@@ -402,7 +416,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
         completed_count = completed_result.scalar() or 0
         completed_count = completed_result.scalar() or 0
         progress = None
         progress = None
         if child.target_count and child.target_count > 0:
         if child.target_count and child.target_count > 0:
-            progress = round((completed_count / child.target_count) * 100, 1)
+            progress = round((int(completed_count) / child.target_count) * 100, 1)
 
 
         previews.append(
         previews.append(
             ProjectChildPreview(
             ProjectChildPreview(

+ 2 - 0
backend/app/api/routes/settings.py

@@ -546,6 +546,7 @@ async def export_backup(
                 "notes": a.notes,
                 "notes": a.notes,
                 "cost": a.cost,
                 "cost": a.cost,
                 "failure_reason": a.failure_reason,
                 "failure_reason": a.failure_reason,
+                "quantity": a.quantity,
                 "energy_kwh": a.energy_kwh,
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "energy_cost": a.energy_cost,
                 "extra_data": a.extra_data,
                 "extra_data": a.extra_data,
@@ -1306,6 +1307,7 @@ async def import_backup(
                     notes=archive_data.get("notes"),
                     notes=archive_data.get("notes"),
                     cost=archive_data.get("cost"),
                     cost=archive_data.get("cost"),
                     failure_reason=archive_data.get("failure_reason"),
                     failure_reason=archive_data.get("failure_reason"),
+                    quantity=archive_data.get("quantity", 1),
                     energy_kwh=archive_data.get("energy_kwh"),
                     energy_kwh=archive_data.get("energy_kwh"),
                     energy_cost=archive_data.get("energy_cost"),
                     energy_cost=archive_data.get("energy_cost"),
                     extra_data=archive_data.get("extra_data"),
                     extra_data=archive_data.get("extra_data"),

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

@@ -375,6 +375,12 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add quantity column to print_archives for tracking item count
+    try:
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1"))
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 5 - 7
backend/app/models/archive.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func
+
+from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -10,9 +11,7 @@ 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)
 
 
     # File info
     # File info
     filename: Mapped[str] = mapped_column(String(255))
     filename: Mapped[str] = mapped_column(String(255))
@@ -54,15 +53,14 @@ class PrintArchive(Base):
     cost: Mapped[float | None] = mapped_column(Float)
     cost: Mapped[float | None] = mapped_column(Float)
     photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
     photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
+    quantity: Mapped[int] = mapped_column(Integer, default=1)  # Number of items printed
 
 
     # Energy tracking
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
 
 
     # Timestamps
     # Timestamps
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
 
     # Relationships
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")

+ 2 - 0
backend/app/schemas/archive.py

@@ -10,6 +10,7 @@ class ArchiveBase(BaseModel):
     notes: str | None = None
     notes: str | None = None
     cost: float | None = None
     cost: float | None = None
     failure_reason: str | None = None
     failure_reason: str | None = None
+    quantity: int | None = None  # Number of items printed
 
 
 
 
 class ArchiveUpdate(ArchiveBase):
 class ArchiveUpdate(ArchiveBase):
@@ -72,6 +73,7 @@ class ArchiveResponse(BaseModel):
     cost: float | None
     cost: float | None
     photos: list | None
     photos: list | None
     failure_reason: str | None
     failure_reason: str | None
+    quantity: int = 1  # Number of items printed
 
 
     # Energy tracking
     # Energy tracking
     energy_kwh: float | None = None
     energy_kwh: float | None = None

+ 6 - 4
backend/app/schemas/project.py

@@ -37,9 +37,10 @@ class ProjectUpdate(BaseModel):
 class ProjectStats(BaseModel):
 class ProjectStats(BaseModel):
     """Statistics for a project."""
     """Statistics for a project."""
 
 
-    total_archives: int = 0
-    completed_prints: int = 0
-    failed_prints: int = 0
+    total_archives: int = 0  # Number of archive records
+    total_items: int = 0  # Sum of quantities (total items printed)
+    completed_prints: int = 0  # Sum of quantities for completed prints
+    failed_prints: int = 0  # Sum of quantities for failed prints
     queued_prints: int = 0
     queued_prints: int = 0
     in_progress_prints: int = 0
     in_progress_prints: int = 0
     total_print_time_hours: float = 0.0
     total_print_time_hours: float = 0.0
@@ -115,7 +116,8 @@ class ProjectListResponse(BaseModel):
     target_count: int | None
     target_count: int | None
     created_at: datetime
     created_at: datetime
     # Quick stats
     # Quick stats
-    archive_count: int = 0
+    archive_count: int = 0  # Number of print jobs
+    total_items: int = 0  # Sum of quantities (total items printed)
     queue_count: int = 0
     queue_count: int = 0
     progress_percent: float | None = None
     progress_percent: float | None = None
     # Preview of archives (up to 5)
     # Preview of archives (up to 5)

+ 19 - 18
backend/app/services/export.py

@@ -3,12 +3,11 @@ import io
 from datetime import datetime
 from datetime import datetime
 from typing import Any
 from typing import Any
 
 
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.models.project import Project
 
 
 
 
 class ExportService:
 class ExportService:
@@ -20,6 +19,7 @@ class ExportService:
         "print_name",
         "print_name",
         "filename",
         "filename",
         "status",
         "status",
+        "quantity",
         "printer_id",
         "printer_id",
         "project_name",
         "project_name",
         "filament_type",
         "filament_type",
@@ -46,6 +46,7 @@ class ExportService:
         "print_name": "Print Name",
         "print_name": "Print Name",
         "filename": "Filename",
         "filename": "Filename",
         "status": "Status",
         "status": "Status",
+        "quantity": "Items Printed",
         "printer_id": "Printer ID",
         "printer_id": "Printer ID",
         "project_name": "Project",
         "project_name": "Project",
         "filament_type": "Filament Type",
         "filament_type": "Filament Type",
@@ -97,9 +98,7 @@ class ExportService:
         """
         """
         # Build query
         # Build query
         query = (
         query = (
-            select(PrintArchive)
-            .options(selectinload(PrintArchive.project))
-            .order_by(PrintArchive.created_at.desc())
+            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
         )
         )
 
 
         # Apply filters
         # Apply filters
@@ -116,11 +115,11 @@ class ExportService:
         if search:
         if search:
             like_pattern = f"%{search}%"
             like_pattern = f"%{search}%"
             query = query.where(
             query = query.where(
-                (PrintArchive.print_name.ilike(like_pattern)) |
-                (PrintArchive.filename.ilike(like_pattern)) |
-                (PrintArchive.tags.ilike(like_pattern)) |
-                (PrintArchive.notes.ilike(like_pattern)) |
-                (PrintArchive.designer.ilike(like_pattern))
+                (PrintArchive.print_name.ilike(like_pattern))
+                | (PrintArchive.filename.ilike(like_pattern))
+                | (PrintArchive.tags.ilike(like_pattern))
+                | (PrintArchive.notes.ilike(like_pattern))
+                | (PrintArchive.designer.ilike(like_pattern))
             )
             )
 
 
         # Execute query
         # Execute query
@@ -212,12 +211,14 @@ class ExportService:
         rows.append(["Week", "Total", "Failed", "Rate (%)"])
         rows.append(["Week", "Total", "Failed", "Rate (%)"])
 
 
         for week in analysis["trend"]:
         for week in analysis["trend"]:
-            rows.append([
-                week["week_start"],
-                week["total_prints"],
-                week["failed_prints"],
-                week["failure_rate"],
-            ])
+            rows.append(
+                [
+                    week["week_start"],
+                    week["total_prints"],
+                    week["failed_prints"],
+                    week["failure_rate"],
+                ]
+            )
 
 
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
 
@@ -266,7 +267,7 @@ class ExportService:
         """Generate Excel file content."""
         """Generate Excel file content."""
         try:
         try:
             from openpyxl import Workbook
             from openpyxl import Workbook
-            from openpyxl.styles import Font, PatternFill, Alignment
+            from openpyxl.styles import Alignment, Font, PatternFill
             from openpyxl.utils import get_column_letter
             from openpyxl.utils import get_column_letter
         except ImportError:
         except ImportError:
             raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
             raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
@@ -293,7 +294,7 @@ class ExportService:
                 ws.cell(row=row_idx, column=col_idx, value=value)
                 ws.cell(row=row_idx, column=col_idx, value=value)
 
 
         # Auto-adjust column widths
         # Auto-adjust column widths
-        for col_idx, field in enumerate(fields, 1):
+        for col_idx, _field in enumerate(fields, 1):
             column_letter = get_column_letter(col_idx)
             column_letter = get_column_letter(col_idx)
             max_length = len(headers[col_idx - 1])
             max_length = len(headers[col_idx - 1])
             for row in rows:
             for row in rows:

+ 3 - 1
frontend/src/pages/ProjectsPage.tsx

@@ -505,11 +505,13 @@ export function ProjectsPage() {
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
     mutationFn: (id: number) => api.deleteProject(id),
     mutationFn: (id: number) => api.deleteProject(id),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['projects'] });
       setDeleteConfirm(null);
       setDeleteConfirm(null);
       showToast('Project deleted', 'success');
       showToast('Project deleted', 'success');
+      // Reload to refresh the list (React Query cache invalidation not working reliably)
+      setTimeout(() => window.location.reload(), 100);
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
+      setDeleteConfirm(null);
       showToast(error.message, 'error');
       showToast(error.message, 'error');
     },
     },
   });
   });

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


+ 1 - 1
static/index.html

@@ -23,7 +23,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-uGH4AzFZ.js"></script>
+    <script type="module" crossorigin src="/assets/index-BaU_6oPV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Da3qKIoX.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Da3qKIoX.css">
   </head>
   </head>
   <body>
   <body>

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