Browse Source

- Fix total print hours calculation in set_total_hours to include all
prints (not just completed), matching get_printer_total_hours behavior
- Add option to keep or delete archives when deleting a printer
- Custom maintenance types no longer auto-assign to all printers
- Add UI to manually assign/remove custom maintenance types per printer
- Add backend endpoints for assigning types to printers and removing items
- Exclude static/assets from large file pre-commit check

maziggy 5 months ago
parent
commit
929c4c8cd6

+ 1 - 0
.pre-commit-config.yaml

@@ -27,6 +27,7 @@ repos:
         exclude: ^static/
       - id: check-added-large-files
         args: ['--maxkb=1000']
+        exclude: ^static/assets/
       - id: check-merge-conflict
       - id: debug-statements
       - id: detect-private-key

+ 77 - 4
backend/app/api/routes/maintenance.py

@@ -231,6 +231,11 @@ async def _get_printer_maintenance_internal(
             last_performed_at = item.last_performed_at
             item_id = item.id
         else:
+            # Only auto-create maintenance items for system types
+            # Custom types need to be manually assigned per printer
+            if not maint_type.is_system:
+                continue
+
             # Create default entry for this printer/type
             item = PrinterMaintenance(
                 printer_id=printer_id,
@@ -373,6 +378,75 @@ async def update_printer_maintenance(
     return item
 
 
+@router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
+async def assign_maintenance_type(
+    printer_id: int,
+    type_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Assign a maintenance type to a specific printer (for custom types)."""
+    # Verify printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Verify maintenance type exists
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
+    maint_type = result.scalar_one_or_none()
+    if not maint_type:
+        raise HTTPException(status_code=404, detail="Maintenance type not found")
+
+    # Check if already assigned
+    result = await db.execute(
+        select(PrinterMaintenance).where(
+            PrinterMaintenance.printer_id == printer_id,
+            PrinterMaintenance.maintenance_type_id == type_id,
+        )
+    )
+    existing = result.scalar_one_or_none()
+    if existing:
+        raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
+
+    # Create the assignment
+    item = PrinterMaintenance(
+        printer_id=printer_id,
+        maintenance_type_id=type_id,
+        enabled=True,
+        last_performed_hours=0.0,
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+
+    return item
+
+
+@router.delete("/items/{item_id}")
+async def remove_maintenance_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Remove a maintenance item (unassign a custom type from a printer)."""
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .where(PrinterMaintenance.id == item_id)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
+
+    # Only allow removing custom (non-system) types
+    if item.maintenance_type.is_system:
+        raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
+
+    await db.delete(item)
+    await db.commit()
+
+    return {"status": "removed"}
+
+
 @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
 async def perform_maintenance(
     item_id: int,
@@ -497,11 +571,10 @@ async def set_printer_hours(
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
 
-    # Get current archive hours
+    # Get current archive hours (all prints, not just completed)
+    # Must match get_printer_total_hours() which includes all prints
     result = await db.execute(
-        select(func.sum(PrintArchive.print_time_seconds))
-        .where(PrintArchive.printer_id == printer_id)
-        .where(PrintArchive.status == "completed")
+        select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
     )
     total_seconds = result.scalar() or 0
     archive_hours = total_seconds / 3600.0

+ 22 - 3
backend/app/api/routes/printers.py

@@ -103,18 +103,37 @@ async def update_printer(
 
 
 @router.delete("/{printer_id}")
-async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Delete a printer."""
+async def delete_printer(
+    printer_id: int,
+    delete_archives: bool = True,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a printer.
+
+    Args:
+        printer_id: ID of the printer to delete
+        delete_archives: If True (default), delete all print archives for this printer.
+                        If False, keep archives but remove their printer association.
+    """
+    from backend.app.models.archive import PrintArchive
+
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
     printer_manager.disconnect_printer(printer_id)
+
+    if not delete_archives:
+        # Orphan the archives instead of deleting them
+        from sqlalchemy import update
+
+        await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
+
     await db.delete(printer)
     await db.commit()
 
-    return {"status": "deleted"}
+    return {"status": "deleted", "archives_deleted": delete_archives}
 
 
 @router.get("/{printer_id}/status", response_model=PrinterStatus)

+ 787 - 38
backend/app/api/routes/projects.py

@@ -1,21 +1,35 @@
 import logging
-from fastapi import APIRouter, Depends, HTTPException
+import os
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.responses import FileResponse
+from sqlalchemy import case, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func
+from sqlalchemy.orm import selectinload
 
+from backend.app.core.config import settings
 from backend.app.core.database import get_db
-from backend.app.models.project import Project
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.project import Project
+from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.schemas.project import (
+    ArchivePreview,
+    BatchAddArchives,
+    BatchAddQueueItems,
+    BOMItemCreate,
+    BOMItemResponse,
+    BOMItemUpdate,
+    ProjectChildPreview,
     ProjectCreate,
-    ProjectUpdate,
-    ProjectResponse,
     ProjectListResponse,
+    ProjectResponse,
     ProjectStats,
-    BatchAddArchives,
-    BatchAddQueueItems,
-    ArchivePreview,
+    ProjectUpdate,
+    TimelineEvent,
 )
 
 logger = logging.getLogger(__name__)
@@ -23,21 +37,16 @@ logger = logging.getLogger(__name__)
 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."""
     # Count total archives
-    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
 
     # Count completed archives
     completed_result = await db.execute(
         select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "completed"
+            PrintArchive.project_id == project_id, PrintArchive.status == "completed"
         )
     )
     completed_prints = completed_result.scalar() or 0
@@ -45,17 +54,19 @@ async def compute_project_stats(
     # Count failed archives
     failed_result = await db.execute(
         select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "failed"
+            PrintArchive.project_id == project_id, PrintArchive.status == "failed"
         )
     )
     failed_prints = failed_result.scalar() or 0
 
-    # Sum print time and filament
+    # Sum print time, filament, and energy
     sums_result = await db.execute(
         select(
             func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
+            func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
+            func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
+            func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
         ).where(PrintArchive.project_id == project_id)
     )
     sums = sums_result.first()
@@ -63,8 +74,7 @@ async def compute_project_stats(
     # Count queued items
     queued_result = await db.execute(
         select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id,
-            PrintQueueItem.status == "pending"
+            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
         )
     )
     queued_prints = queued_result.scalar() or 0
@@ -72,16 +82,28 @@ async def compute_project_stats(
     # Count in-progress items
     in_progress_result = await db.execute(
         select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id,
-            PrintQueueItem.status == "printing"
+            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
         )
     )
     in_progress_prints = in_progress_result.scalar() or 0
 
     # Calculate progress
     progress_percent = None
+    remaining_prints = None
     if target_count and target_count > 0:
         progress_percent = round((completed_prints / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - completed_prints)
+
+    # BOM stats
+    bom_result = await db.execute(
+        select(
+            func.count(ProjectBOMItem.id).label("total"),
+            func.sum(case((ProjectBOMItem.quantity_printed >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+                "completed"
+            ),
+        ).where(ProjectBOMItem.project_id == project_id)
+    )
+    bom_stats = bom_result.first()
 
     return ProjectStats(
         total_archives=total_archives,
@@ -92,6 +114,12 @@ async def compute_project_stats(
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_filament_grams=round(sums.total_filament or 0, 2),
         progress_percent=progress_percent,
+        estimated_cost=round((sums.total_filament_cost or 0), 2),
+        total_energy_kwh=round((sums.total_energy or 0), 3),
+        total_energy_cost=round((sums.total_energy_cost or 0), 2),
+        remaining_prints=remaining_prints,
+        bom_total_items=bom_stats.total or 0,
+        bom_completed_items=int(bom_stats.completed or 0),
     )
 
 
@@ -115,9 +143,7 @@ async def list_projects(
     for project in projects:
         # Get archive count
         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
 
@@ -186,14 +212,144 @@ async def create_project(
     db: AsyncSession = Depends(get_db),
 ):
     """Create a new project."""
+    # Verify parent exists if specified
+    parent_name = None
+    if data.parent_id:
+        parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
+        parent = parent_result.scalar_one_or_none()
+        if not parent:
+            raise HTTPException(status_code=400, detail="Parent project not found")
+        parent_name = parent.name
+
     project = Project(
         name=data.name,
         description=data.description,
         color=data.color,
         target_count=data.target_count,
+        notes=data.notes,
+        tags=data.tags,
+        due_date=data.due_date,
+        priority=data.priority,
+        budget=data.budget,
+        parent_id=data.parent_id,
+    )
+    db.add(project)
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+# ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
+
+
+@router.get("/templates", response_model=list[ProjectListResponse])
+async def list_templates(
+    db: AsyncSession = Depends(get_db),
+):
+    """List all project templates."""
+    result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
+    templates = result.scalars().all()
+
+    response = []
+    for project in templates:
+        # Get archive count
+        archive_count_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
+        )
+        archive_count = archive_count_result.scalar() or 0
+
+        response.append(
+            ProjectListResponse(
+                id=project.id,
+                name=project.name,
+                description=project.description,
+                color=project.color,
+                status=project.status,
+                target_count=project.target_count,
+                created_at=project.created_at,
+                archive_count=archive_count,
+                queue_count=0,
+                progress_percent=None,
+                archives=[],
+            )
+        )
+
+    return response
+
+
+@router.post("/from-template/{template_id}", response_model=ProjectResponse)
+async def create_project_from_template(
+    template_id: int,
+    name: str = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new project from a template."""
+    result = await db.execute(select(Project).where(Project.id == template_id))
+    template = result.scalar_one_or_none()
+
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    if not template.is_template:
+        raise HTTPException(status_code=400, detail="Project is not a template")
+
+    # Create new project
+    project = Project(
+        name=name or template.name.replace(" (Template)", ""),
+        description=template.description,
+        color=template.color,
+        target_count=template.target_count,
+        notes=template.notes,
+        tags=template.tags,
+        priority=template.priority,
+        budget=template.budget,
+        is_template=False,
+        template_source_id=template.id,
     )
     db.add(project)
     await db.flush()
+
+    # Copy BOM items
+    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
+    bom_items = bom_result.scalars().all()
+
+    for item in bom_items:
+        new_item = ProjectBOMItem(
+            project_id=project.id,
+            name=item.name,
+            quantity_needed=item.quantity_needed,
+            quantity_printed=0,
+            stl_filename=item.stl_filename,
+            notes=item.notes,
+            sort_order=item.sort_order,
+        )
+        db.add(new_item)
+
+    await db.flush()
     await db.refresh(project)
 
     stats = await compute_project_stats(db, project.id, project.target_count)
@@ -205,12 +361,57 @@ async def create_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
     )
 
 
+# ============ Dynamic {project_id} Routes ============
+
+
+async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
+    """Get preview info for child projects."""
+    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
+    children = result.scalars().all()
+
+    previews = []
+    for child in children:
+        # Get completed count for progress
+        completed_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(
+                PrintArchive.project_id == child.id,
+                PrintArchive.status == "completed",
+            )
+        )
+        completed_count = completed_result.scalar() or 0
+        progress = None
+        if child.target_count and child.target_count > 0:
+            progress = round((completed_count / child.target_count) * 100, 1)
+
+        previews.append(
+            ProjectChildPreview(
+                id=child.id,
+                name=child.name,
+                color=child.color,
+                status=child.status,
+                progress_percent=progress,
+            )
+        )
+    return previews
+
+
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: int,
@@ -223,6 +424,15 @@ async def get_project(
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
 
+    # Get parent name
+    parent_name = None
+    if project.parent_id:
+        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
+        parent_name = parent_result.scalar()
+
+    # Get children
+    children = await get_child_previews(db, project.id)
+
     stats = await compute_project_stats(db, project.id, project.target_count)
 
     return ProjectResponse(
@@ -232,6 +442,17 @@ async def get_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=children,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
@@ -264,10 +485,42 @@ async def update_project(
         project.status = data.status
     if data.target_count is not None:
         project.target_count = data.target_count
+    if data.notes is not None:
+        project.notes = data.notes
+    if data.tags is not None:
+        project.tags = data.tags
+    if data.due_date is not None:
+        project.due_date = data.due_date
+    if data.priority is not None:
+        if data.priority not in ["low", "normal", "high", "urgent"]:
+            raise HTTPException(status_code=400, detail="Invalid priority")
+        project.priority = data.priority
+    if data.budget is not None:
+        project.budget = data.budget
+    if data.parent_id is not None:
+        # Verify parent exists and prevent circular reference
+        if data.parent_id == project_id:
+            raise HTTPException(status_code=400, detail="Project cannot be its own parent")
+        if data.parent_id != 0:  # 0 means remove parent
+            parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
+            if not parent_result.scalar_one_or_none():
+                raise HTTPException(status_code=400, detail="Parent project not found")
+            project.parent_id = data.parent_id
+        else:
+            project.parent_id = None
 
     await db.flush()
     await db.refresh(project)
 
+    # Get parent name
+    parent_name = None
+    if project.parent_id:
+        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
+        parent_name = parent_result.scalar()
+
+    # Get children
+    children = await get_child_previews(db, project.id)
+
     stats = await compute_project_stats(db, project.id, project.target_count)
 
     return ProjectResponse(
@@ -277,6 +530,17 @@ async def update_project(
         color=project.color,
         status=project.status,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=children,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
@@ -313,9 +577,10 @@ async def list_project_archives(
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
 
-    # Get archives
+    # Get archives with project relationship eagerly loaded
     query = (
         select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
         .where(PrintArchive.project_id == project_id)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
@@ -342,11 +607,7 @@ async def list_project_queue(
         raise HTTPException(status_code=404, detail="Project not found")
 
     # Get queue items
-    query = (
-        select(PrintQueueItem)
-        .where(PrintQueueItem.project_id == project_id)
-        .order_by(PrintQueueItem.position)
-    )
+    query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
     result = await db.execute(query)
     items = result.scalars().all()
 
@@ -368,9 +629,7 @@ async def add_archives_to_project(
     # Update archives
     updated = 0
     for archive_id in data.archive_ids:
-        result = await db.execute(
-            select(PrintArchive).where(PrintArchive.id == archive_id)
-        )
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = result.scalar_one_or_none()
         if archive:
             archive.project_id = project_id
@@ -394,9 +653,7 @@ async def add_queue_items_to_project(
     # Update queue items
     updated = 0
     for item_id in data.queue_item_ids:
-        result = await db.execute(
-            select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-        )
+        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
         item = result.scalar_one_or_none()
         if item:
             item.project_id = project_id
@@ -426,3 +683,495 @@ async def remove_archives_from_project(
             updated += 1
 
     return {"message": f"Removed {updated} archives from project"}
+
+
+def get_project_attachments_dir(project_id: int) -> Path:
+    """Get the attachments directory for a project."""
+    base_dir = Path(settings.archive_dir)
+    return base_dir / "projects" / str(project_id) / "attachments"
+
+
+@router.post("/{project_id}/attachments")
+async def upload_attachment(
+    project_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload an attachment to a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Create attachments directory
+    attachments_dir = get_project_attachments_dir(project_id)
+    attachments_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate unique filename
+    original_name = file.filename or "unknown"
+    ext = os.path.splitext(original_name)[1]
+    unique_filename = f"{uuid.uuid4().hex}{ext}"
+    file_path = attachments_dir / unique_filename
+
+    # Save file
+    try:
+        with open(file_path, "wb") as f:
+            content = await file.read()
+            f.write(content)
+    except Exception as e:
+        logger.error(f"Failed to save attachment: {e}")
+        raise HTTPException(status_code=500, detail="Failed to save attachment")
+
+    # Update project attachments JSON
+    attachments = project.attachments or []
+    attachments.append(
+        {
+            "filename": unique_filename,
+            "original_name": original_name,
+            "size": len(content),
+            "uploaded_at": datetime.now().isoformat(),
+        }
+    )
+    project.attachments = attachments
+
+    await db.flush()
+    await db.refresh(project)
+
+    return {
+        "status": "success",
+        "filename": unique_filename,
+        "original_name": original_name,
+        "attachments": project.attachments,
+    }
+
+
+@router.get("/{project_id}/attachments/{filename}")
+async def download_attachment(
+    project_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download an attachment from a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Verify attachment exists in project
+    attachments = project.attachments or []
+    attachment = next((a for a in attachments if a.get("filename") == filename), None)
+    if not attachment:
+        raise HTTPException(status_code=404, detail="Attachment not found")
+
+    # Check file exists
+    file_path = get_project_attachments_dir(project_id) / filename
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="Attachment file not found")
+
+    return FileResponse(
+        file_path,
+        filename=attachment.get("original_name", filename),
+        media_type="application/octet-stream",
+    )
+
+
+@router.delete("/{project_id}/attachments/{filename}")
+async def delete_attachment(
+    project_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete an attachment from a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Find and remove attachment from list
+    attachments = project.attachments or []
+    attachment = next((a for a in attachments if a.get("filename") == filename), None)
+    if not attachment:
+        raise HTTPException(status_code=404, detail="Attachment not found")
+
+    # Remove from list
+    attachments = [a for a in attachments if a.get("filename") != filename]
+    project.attachments = attachments if attachments else None
+
+    # Delete file
+    file_path = get_project_attachments_dir(project_id) / filename
+    if file_path.exists():
+        try:
+            os.remove(file_path)
+        except Exception as e:
+            logger.warning(f"Failed to delete attachment file: {e}")
+
+    await db.flush()
+    await db.refresh(project)
+
+    return {
+        "status": "success",
+        "message": "Attachment deleted",
+        "attachments": project.attachments,
+    }
+
+
+# ============ Phase 7: BOM Endpoints ============
+
+
+@router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
+async def list_bom_items(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """List all BOM items for a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get BOM items
+    result = await db.execute(
+        select(ProjectBOMItem)
+        .where(ProjectBOMItem.project_id == project_id)
+        .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
+    )
+    items = result.scalars().all()
+
+    response = []
+    for item in items:
+        # Get archive name if linked
+        archive_name = None
+        if item.archive_id:
+            archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+            archive_name = archive_result.scalar()
+
+        response.append(
+            BOMItemResponse(
+                id=item.id,
+                project_id=item.project_id,
+                name=item.name,
+                quantity_needed=item.quantity_needed,
+                quantity_printed=item.quantity_printed,
+                archive_id=item.archive_id,
+                archive_name=archive_name,
+                stl_filename=item.stl_filename,
+                notes=item.notes,
+                sort_order=item.sort_order,
+                is_complete=item.quantity_printed >= item.quantity_needed,
+                created_at=item.created_at,
+                updated_at=item.updated_at,
+            )
+        )
+
+    return response
+
+
+@router.post("/{project_id}/bom", response_model=BOMItemResponse)
+async def create_bom_item(
+    project_id: int,
+    data: BOMItemCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a BOM item to a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get max sort order
+    max_order_result = await db.execute(
+        select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
+    )
+    max_order = max_order_result.scalar() or 0
+
+    item = ProjectBOMItem(
+        project_id=project_id,
+        name=data.name,
+        quantity_needed=data.quantity_needed,
+        archive_id=data.archive_id,
+        stl_filename=data.stl_filename,
+        notes=data.notes,
+        sort_order=max_order + 1,
+    )
+    db.add(item)
+    await db.flush()
+    await db.refresh(item)
+
+    # Get archive name if linked
+    archive_name = None
+    if item.archive_id:
+        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+        archive_name = archive_result.scalar()
+
+    return BOMItemResponse(
+        id=item.id,
+        project_id=item.project_id,
+        name=item.name,
+        quantity_needed=item.quantity_needed,
+        quantity_printed=item.quantity_printed,
+        archive_id=item.archive_id,
+        archive_name=archive_name,
+        stl_filename=item.stl_filename,
+        notes=item.notes,
+        sort_order=item.sort_order,
+        is_complete=item.quantity_printed >= item.quantity_needed,
+        created_at=item.created_at,
+        updated_at=item.updated_at,
+    )
+
+
+@router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
+async def update_bom_item(
+    project_id: int,
+    item_id: int,
+    data: BOMItemUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a BOM item."""
+    result = await db.execute(
+        select(ProjectBOMItem).where(
+            ProjectBOMItem.id == item_id,
+            ProjectBOMItem.project_id == project_id,
+        )
+    )
+    item = result.scalar_one_or_none()
+
+    if not item:
+        raise HTTPException(status_code=404, detail="BOM item not found")
+
+    if data.name is not None:
+        item.name = data.name
+    if data.quantity_needed is not None:
+        item.quantity_needed = data.quantity_needed
+    if data.quantity_printed is not None:
+        item.quantity_printed = data.quantity_printed
+    if data.archive_id is not None:
+        item.archive_id = data.archive_id if data.archive_id != 0 else None
+    if data.stl_filename is not None:
+        item.stl_filename = data.stl_filename if data.stl_filename else None
+    if data.notes is not None:
+        item.notes = data.notes if data.notes else None
+
+    await db.flush()
+    await db.refresh(item)
+
+    # Get archive name if linked
+    archive_name = None
+    if item.archive_id:
+        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+        archive_name = archive_result.scalar()
+
+    return BOMItemResponse(
+        id=item.id,
+        project_id=item.project_id,
+        name=item.name,
+        quantity_needed=item.quantity_needed,
+        quantity_printed=item.quantity_printed,
+        archive_id=item.archive_id,
+        archive_name=archive_name,
+        stl_filename=item.stl_filename,
+        notes=item.notes,
+        sort_order=item.sort_order,
+        is_complete=item.quantity_printed >= item.quantity_needed,
+        created_at=item.created_at,
+        updated_at=item.updated_at,
+    )
+
+
+@router.delete("/{project_id}/bom/{item_id}")
+async def delete_bom_item(
+    project_id: int,
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a BOM item."""
+    result = await db.execute(
+        select(ProjectBOMItem).where(
+            ProjectBOMItem.id == item_id,
+            ProjectBOMItem.project_id == project_id,
+        )
+    )
+    item = result.scalar_one_or_none()
+
+    if not item:
+        raise HTTPException(status_code=404, detail="BOM item not found")
+
+    await db.delete(item)
+
+    return {"status": "success", "message": "BOM item deleted"}
+
+
+@router.post("/{project_id}/create-template", response_model=ProjectResponse)
+async def create_template_from_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a template from an existing project."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    source = result.scalar_one_or_none()
+
+    if not source:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Create template
+    template = Project(
+        name=f"{source.name} (Template)",
+        description=source.description,
+        color=source.color,
+        target_count=source.target_count,
+        notes=source.notes,
+        tags=source.tags,
+        priority=source.priority,
+        budget=source.budget,
+        is_template=True,
+        template_source_id=source.id,
+    )
+    db.add(template)
+    await db.flush()
+
+    # Copy BOM items
+    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
+    bom_items = bom_result.scalars().all()
+
+    for item in bom_items:
+        new_item = ProjectBOMItem(
+            project_id=template.id,
+            name=item.name,
+            quantity_needed=item.quantity_needed,
+            quantity_printed=0,
+            stl_filename=item.stl_filename,
+            notes=item.notes,
+            sort_order=item.sort_order,
+        )
+        db.add(new_item)
+
+    await db.flush()
+    await db.refresh(template)
+
+    stats = await compute_project_stats(db, template.id, template.target_count)
+
+    return ProjectResponse(
+        id=template.id,
+        name=template.name,
+        description=template.description,
+        color=template.color,
+        status=template.status,
+        target_count=template.target_count,
+        notes=template.notes,
+        attachments=template.attachments,
+        tags=template.tags,
+        due_date=template.due_date,
+        priority=template.priority,
+        budget=template.budget,
+        is_template=template.is_template,
+        template_source_id=template.template_source_id,
+        parent_id=template.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=template.created_at,
+        updated_at=template.updated_at,
+        stats=stats,
+    )
+
+
+# ============ Phase 9: Timeline Endpoint ============
+
+
+@router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
+async def get_project_timeline(
+    project_id: int,
+    limit: int = 50,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get timeline of events for a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    events = []
+
+    # Project creation event
+    events.append(
+        TimelineEvent(
+            event_type="project_created",
+            timestamp=project.created_at,
+            title="Project created",
+            description=f"Project '{project.name}' was created",
+        )
+    )
+
+    # Get archives and add events
+    archives_result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.project_id == project_id)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+    )
+    archives = archives_result.scalars().all()
+
+    for archive in archives:
+        if archive.status == "completed":
+            events.append(
+                TimelineEvent(
+                    event_type="print_completed",
+                    timestamp=archive.completed_at or archive.created_at,
+                    title="Print completed",
+                    description=archive.print_name,
+                    metadata={
+                        "archive_id": archive.id,
+                        "print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
+                        "filament_grams": round(archive.filament_used_grams or 0, 1),
+                    },
+                )
+            )
+        elif archive.status == "failed":
+            events.append(
+                TimelineEvent(
+                    event_type="print_failed",
+                    timestamp=archive.completed_at or archive.created_at,
+                    title="Print failed",
+                    description=archive.print_name,
+                    metadata={"archive_id": archive.id},
+                )
+            )
+
+    # Get queue items
+    queue_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.project_id == project_id)
+        .order_by(PrintQueueItem.created_at.desc())
+        .limit(limit)
+    )
+    queue_items = queue_result.scalars().all()
+
+    for item in queue_items:
+        if item.status == "printing":
+            events.append(
+                TimelineEvent(
+                    event_type="print_started",
+                    timestamp=item.started_at or item.created_at,
+                    title="Print started",
+                    description=item.print_name,
+                    metadata={"queue_item_id": item.id},
+                )
+            )
+        elif item.status == "pending":
+            events.append(
+                TimelineEvent(
+                    event_type="queued",
+                    timestamp=item.created_at,
+                    title="Added to queue",
+                    description=item.print_name,
+                    metadata={"queue_item_id": item.id},
+                )
+            )
+
+    # Sort by timestamp descending
+    events.sort(key=lambda e: e.timestamp, reverse=True)
+
+    return events[:limit]

+ 127 - 90
backend/app/core/database.py

@@ -1,9 +1,8 @@
-from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 
 from backend.app.core.config import settings
 
-
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
@@ -34,7 +33,22 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key  # noqa: F401
+    from backend.app.models import (  # noqa: F401
+        api_key,
+        archive,
+        external_link,
+        filament,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        smart_plug,
+    )
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -52,164 +66,129 @@ async def run_migrations(conn):
 
     # Migration: Add is_favorite column to print_archives
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add content_hash column to print_archives for duplicate detection
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add auto_off_executed column to smart_plugs
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add on_print_stopped column to notification_providers
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add source_3mf_path column to print_archives
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add on_maintenance_due column to notification_providers
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add location column to printers for grouping
     try:
-        await conn.execute(text(
-            "ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
-        ))
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add interval_type column to maintenance_types
     try:
-        await conn.execute(text(
-            "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"
-        ))
+        await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add custom_interval_type column to printer_maintenance
     try:
-        await conn.execute(text(
-            "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"
-        ))
+        await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
     except Exception:
         # Column already exists
         pass
 
     # Migration: Add power alert columns to smart_plugs
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
     except Exception:
         pass
 
     # Migration: Add schedule columns to smart_plugs
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
     except Exception:
         pass
 
     # Migration: Add daily digest columns to notification_providers
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
     except Exception:
         pass
 
     # Migration: Add project_id column to print_archives
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
-        ))
+        await conn.execute(
+            text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
     except Exception:
         pass
 
     # Migration: Add project_id column to print_queue
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
-        ))
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
     except Exception:
         pass
 
     # Migration: Create FTS5 virtual table for archive full-text search
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
                 print_name,
                 filename,
@@ -220,82 +199,139 @@ async def run_migrations(conn):
                 content='print_archives',
                 content_rowid='id'
             )
-        """))
+        """)
+        )
     except Exception:
         pass
 
     # Migration: Create triggers to keep FTS index in sync
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
     except Exception:
         pass
 
     # Migration: Add AMS alarm notification columns to notification_providers
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
     except Exception:
         pass
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
+        )
     except Exception:
         pass
 
     # Migration: Add AMS-HT alarm notification columns to notification_providers
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
+        )
+    except Exception:
+        pass
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add notes column to projects (Phase 2)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
+    except Exception:
+        pass
+
+    # Migration: Add attachments column to projects (Phase 3)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
+    except Exception:
+        pass
+
+    # Migration: Add tags column to projects (Phase 4)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
+    except Exception:
+        pass
+
+    # Migration: Add due_date column to projects (Phase 5)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
+    except Exception:
+        pass
+
+    # Migration: Add priority column to projects (Phase 5)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
     except Exception:
         pass
+
+    # Migration: Add budget column to projects (Phase 6)
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
+    except Exception:
+        pass
+
+    # Migration: Add is_template column to projects (Phase 8)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add template_source_id column to projects (Phase 8)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
+    except Exception:
+        pass
+
+    # Migration: Add parent_id column to projects (Phase 10)
+    try:
+        await conn.execute(
+            text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
     except Exception:
         pass
 
@@ -303,7 +339,8 @@ async def run_migrations(conn):
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     from sqlalchemy import select
-    from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+
+    from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 
     async with async_session() as session:
         # Check if templates already exist

+ 40 - 4
backend/app/models/project.py

@@ -1,5 +1,6 @@
 from datetime import datetime
-from sqlalchemy import String, Integer, DateTime, Text, func
+
+from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,16 +18,51 @@ class Project(Base):
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
     target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
 
+    # Phase 2: Rich text notes (HTML from WYSIWYG editor)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Phase 3: File attachments stored as JSON array
+    # Format: [{"filename": "x.stl", "original_name": "part.stl", "size": 1234, "uploaded_at": "..."}]
+    attachments: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
+    # Phase 4: Tags (comma-separated)
+    tags: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Phase 5: Due dates and priority
+    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    priority: Mapped[str] = mapped_column(String(20), default="normal")  # low, normal, high, urgent
+
+    # Phase 6: Budget tracking
+    budget: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    # Phase 8: Templates
+    is_template: Mapped[bool] = mapped_column(Boolean, default=False)
+    template_source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # Phase 10: Sub-projects (hierarchical)
+    parent_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id"), nullable=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     # Relationships
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
+    children: Mapped[list["Project"]] = relationship(
+        "Project",
+        back_populates="parent",
+        foreign_keys="Project.parent_id",
+    )
+    parent: Mapped["Project | None"] = relationship(
+        "Project",
+        back_populates="children",
+        remote_side="Project.id",
+        foreign_keys="Project.parent_id",
+    )
+    bom_items: Mapped[list["ProjectBOMItem"]] = relationship(back_populates="project", cascade="all, delete-orphan")
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
+from backend.app.models.project_bom import ProjectBOMItem  # noqa: E402

+ 42 - 0
backend/app/models/project_bom.py

@@ -0,0 +1,42 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class ProjectBOMItem(Base):
+    """Bill of Materials item for a project."""
+
+    __tablename__ = "project_bom_items"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    name: Mapped[str] = mapped_column(String(255))
+    quantity_needed: Mapped[int] = mapped_column(Integer, default=1)
+    quantity_printed: Mapped[int] = mapped_column(Integer, default=0)
+
+    # Optional link to archive that prints this part
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+
+    # Reference to attachment filename (STL file)
+    stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+    # Notes about this part
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Sort order
+    sort_order: Mapped[int] = mapped_column(Integer, default=0)
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationships
+    project: Mapped["Project"] = relationship(back_populates="bom_items")
+    archive: Mapped["PrintArchive | None"] = relationship()
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.project import Project  # noqa: E402

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

@@ -1,26 +1,42 @@
 from datetime import datetime
+
 from pydantic import BaseModel
 
 
 class ProjectCreate(BaseModel):
     """Schema for creating a new project."""
+
     name: str
     description: str | None = None
     color: str | None = None
     target_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    parent_id: int | None = None  # For sub-projects
 
 
 class ProjectUpdate(BaseModel):
     """Schema for updating a project."""
+
     name: str | None = None
     description: str | None = None
     color: str | None = None
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str | None = None
+    budget: float | None = None
+    parent_id: int | None = None
 
 
 class ProjectStats(BaseModel):
     """Statistics for a project."""
+
     total_archives: int = 0
     completed_prints: int = 0
     failed_prints: int = 0
@@ -29,16 +45,46 @@ class ProjectStats(BaseModel):
     total_print_time_hours: float = 0.0
     total_filament_grams: float = 0.0
     progress_percent: float | None = None  # Based on target_count
+    # Cost tracking (Phase 6)
+    estimated_cost: float = 0.0  # Based on filament cost
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
+    remaining_prints: int | None = None  # target_count - completed_prints
+    # BOM stats (Phase 7)
+    bom_total_items: int = 0
+    bom_completed_items: int = 0
+
+
+class ProjectChildPreview(BaseModel):
+    """Minimal project data for child preview."""
+
+    id: int
+    name: str
+    color: str | None
+    status: str
+    progress_percent: float | None = None
 
 
 class ProjectResponse(BaseModel):
     """Schema for project response."""
+
     id: int
     name: str
     description: str | None
     color: str | None
     status: str
     target_count: int | None
+    notes: str | None = None
+    attachments: list | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    is_template: bool = False
+    template_source_id: int | None = None
+    parent_id: int | None = None
+    parent_name: str | None = None  # For display
+    children: list[ProjectChildPreview] = []
     created_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
@@ -49,6 +95,7 @@ class ProjectResponse(BaseModel):
 
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
+
     id: int
     print_name: str | None
     thumbnail_path: str | None
@@ -57,6 +104,7 @@ class ArchivePreview(BaseModel):
 
 class ProjectListResponse(BaseModel):
     """Schema for project list item (lighter weight)."""
+
     id: int
     name: str
     description: str | None
@@ -77,9 +125,65 @@ class ProjectListResponse(BaseModel):
 
 class BatchAddArchives(BaseModel):
     """Schema for batch adding archives to a project."""
+
     archive_ids: list[int]
 
 
 class BatchAddQueueItems(BaseModel):
     """Schema for batch adding queue items to a project."""
+
     queue_item_ids: list[int]
+
+
+# Phase 7: BOM Schemas
+class BOMItemCreate(BaseModel):
+    """Schema for creating a BOM item."""
+
+    name: str
+    quantity_needed: int = 1
+    archive_id: int | None = None
+    stl_filename: str | None = None
+    notes: str | None = None
+
+
+class BOMItemUpdate(BaseModel):
+    """Schema for updating a BOM item."""
+
+    name: str | None = None
+    quantity_needed: int | None = None
+    quantity_printed: int | None = None
+    archive_id: int | None = None
+    stl_filename: str | None = None
+    notes: str | None = None
+
+
+class BOMItemResponse(BaseModel):
+    """Schema for BOM item response."""
+
+    id: int
+    project_id: int
+    name: str
+    quantity_needed: int
+    quantity_printed: int
+    archive_id: int | None
+    archive_name: str | None = None
+    stl_filename: str | None
+    notes: str | None
+    sort_order: int
+    is_complete: bool = False
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+# Phase 9: Timeline Schemas
+class TimelineEvent(BaseModel):
+    """Schema for a timeline event."""
+
+    event_type: str  # archive_added, queue_started, queue_completed, status_changed, note_updated
+    timestamp: datetime
+    title: str
+    description: str | None = None
+    metadata: dict | None = None  # Additional event-specific data

+ 2 - 0
frontend/src/App.tsx

@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
+import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -49,6 +50,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
+                  <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />

+ 162 - 2
frontend/src/api/client.ts

@@ -106,12 +106,16 @@ export interface PrinterStatus {
   temperatures: {
     bed?: number;
     bed_target?: number;
+    bed_heating?: boolean;  // Actual heater state from MQTT
     nozzle?: number;
     nozzle_target?: number;
+    nozzle_heating?: boolean;  // Actual heater state from MQTT
     nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
     nozzle_2_target?: number;
+    nozzle_2_heating?: boolean;  // Actual heater state from MQTT
     chamber?: number;
     chamber_target?: number;
+    chamber_heating?: boolean;  // Actual heater state from MQTT
   } | null;
   cover_url: string | null;
   hms_errors: HMSError[];
@@ -329,6 +333,20 @@ export interface ProjectStats {
   total_print_time_hours: number;
   total_filament_grams: number;
   progress_percent: number | null;
+  estimated_cost: number;
+  total_energy_kwh: number;
+  total_energy_cost: number;
+  remaining_prints: number | null;
+  bom_total_items: number;
+  bom_completed_items: number;
+}
+
+export interface ProjectChildPreview {
+  id: number;
+  name: string;
+  color: string | null;
+  status: string;
+  progress_percent: number | null;
 }
 
 export interface Project {
@@ -338,11 +356,29 @@ export interface Project {
   color: string | null;
   status: string;  // active, completed, archived
   target_count: number | null;
+  notes: string | null;
+  attachments: ProjectAttachment[] | null;
+  tags: string | null;
+  due_date: string | null;
+  priority: string;  // low, normal, high, urgent
+  budget: number | null;
+  is_template: boolean;
+  template_source_id: number | null;
+  parent_id: number | null;
+  parent_name: string | null;
+  children: ProjectChildPreview[];
   created_at: string;
   updated_at: string;
   stats?: ProjectStats;
 }
 
+export interface ProjectAttachment {
+  filename: string;
+  original_name: string;
+  size: number;
+  uploaded_at: string;
+}
+
 export interface ArchivePreview {
   id: number;
   print_name: string | null;
@@ -369,6 +405,12 @@ export interface ProjectCreate {
   description?: string;
   color?: string;
   target_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  parent_id?: number;
 }
 
 export interface ProjectUpdate {
@@ -377,6 +419,55 @@ export interface ProjectUpdate {
   color?: string;
   status?: string;
   target_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  parent_id?: number;
+}
+
+// BOM Types
+export interface BOMItem {
+  id: number;
+  project_id: number;
+  name: string;
+  quantity_needed: number;
+  quantity_printed: number;
+  archive_id: number | null;
+  archive_name: string | null;
+  stl_filename: string | null;
+  notes: string | null;
+  sort_order: number;
+  is_complete: boolean;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface BOMItemCreate {
+  name: string;
+  quantity_needed?: number;
+  archive_id?: number;
+  stl_filename?: string;
+  notes?: string;
+}
+
+export interface BOMItemUpdate {
+  name?: string;
+  quantity_needed?: number;
+  quantity_printed?: number;
+  archive_id?: number;
+  stl_filename?: string;
+  notes?: string;
+}
+
+// Timeline Types
+export interface TimelineEvent {
+  event_type: string;
+  timestamp: string;
+  title: string;
+  description: string | null;
+  metadata: Record<string, unknown> | null;
 }
 
 // API Key types
@@ -1122,8 +1213,11 @@ export const api = {
       method: 'PATCH',
       body: JSON.stringify(data),
     }),
-  deletePrinter: (id: number) =>
-    request<void>(`/printers/${id}`, { method: 'DELETE' }),
+  deletePrinter: (id: number, deleteArchives: boolean = true) =>
+    request<{ status: string; archives_deleted: boolean }>(
+      `/printers/${id}?delete_archives=${deleteArchives}`,
+      { method: 'DELETE' }
+    ),
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
   connectPrinter: (id: number) =>
@@ -1820,6 +1914,14 @@ export const api = {
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       { method: 'PATCH' }
     ),
+  assignMaintenanceType: (printerId: number, typeId: number) =>
+    request<MaintenanceStatus>(`/maintenance/printers/${printerId}/assign/${typeId}`, {
+      method: 'POST',
+    }),
+  removeMaintenanceItem: (itemId: number) =>
+    request<{ status: string }>(`/maintenance/items/${itemId}`, {
+      method: 'DELETE',
+    }),
 
   // Camera
   getCameraStreamUrl: (printerId: number, fps = 10) =>
@@ -1903,6 +2005,64 @@ export const api = {
       body: JSON.stringify({ queue_item_ids: queueItemIds }),
     }),
 
+  // Project Attachments
+  uploadProjectAttachment: async (projectId: number, file: File): Promise<{
+    status: string;
+    filename: string;
+    original_name: string;
+    attachments: ProjectAttachment[];
+  }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  getProjectAttachmentUrl: (projectId: number, filename: string) =>
+    `${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
+  deleteProjectAttachment: (projectId: number, filename: string) =>
+    request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(
+      `/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
+      { method: 'DELETE' }
+    ),
+
+  // BOM (Bill of Materials)
+  getProjectBOM: (projectId: number) =>
+    request<BOMItem[]>(`/projects/${projectId}/bom`),
+  createBOMItem: (projectId: number, data: BOMItemCreate) =>
+    request<BOMItem>(`/projects/${projectId}/bom`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) =>
+    request<BOMItem>(`/projects/${projectId}/bom/${itemId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteBOMItem: (projectId: number, itemId: number) =>
+    request<{ status: string; message: string }>(`/projects/${projectId}/bom/${itemId}`, {
+      method: 'DELETE',
+    }),
+
+  // Templates
+  getTemplates: () => request<ProjectListItem[]>('/projects/templates/'),
+  createTemplateFromProject: (projectId: number) =>
+    request<Project>(`/projects/${projectId}/create-template`, { method: 'POST' }),
+  createProjectFromTemplate: (templateId: number, name?: string) =>
+    request<Project>(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, {
+      method: 'POST',
+    }),
+
+  // Timeline
+  getProjectTimeline: (projectId: number, limit = 50) =>
+    request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
+
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>

+ 176 - 23
frontend/src/pages/MaintenancePage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Wrench,
@@ -32,6 +32,7 @@ import {
   Settings,
   Filter,
   CircleDot,
+  Printer,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
@@ -401,13 +402,17 @@ function SettingsSection({
   onAddType,
   onUpdateType,
   onDeleteType,
+  onAssignType,
+  onRemoveItem,
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
-  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }) => void;
+  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
   onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
   onDeleteType: (id: number) => void;
+  onAssignType: (printerId: number, typeId: number) => void;
+  onRemoveItem: (itemId: number) => void;
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
@@ -417,6 +422,33 @@ function SettingsSection({
   const [newTypeInterval, setNewTypeInterval] = useState('100');
   const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
   const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
+  const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
+  const [expandedType, setExpandedType] = useState<number | null>(null);
+
+  // Get unique printers from overview
+  const printers = useMemo(() => {
+    if (!overview) return [];
+    return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));
+  }, [overview]);
+
+  // Get which printers have a specific maintenance type assigned
+  const getAssignedPrinters = (typeId: number) => {
+    if (!overview) return [];
+    return overview
+      .filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))
+      .map(p => ({
+        printerId: p.printer_id,
+        printerName: p.printer_name,
+        itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,
+      }));
+  };
+
+  // Get printers that DON'T have a specific type assigned
+  const getUnassignedPrinters = (typeId: number) => {
+    if (!overview) return [];
+    const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));
+    return printers.filter(p => !assignedIds.has(p.id));
+  };
 
   // Edit type state
   const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
@@ -460,20 +492,33 @@ function SettingsSection({
 
   const handleAddType = (e: React.FormEvent) => {
     e.preventDefault();
-    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
+    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
       onAddType({
         name: newTypeName.trim(),
         default_interval_hours: parseFloat(newTypeInterval),
         interval_type: newTypeIntervalType,
         icon: newTypeIcon,
-      });
+      }, Array.from(selectedPrinters));
       setNewTypeName('');
       setNewTypeInterval('100');
       setNewTypeIntervalType('hours');
+      setSelectedPrinters(new Set());
       setShowAddType(false);
     }
   };
 
+  const togglePrinterSelection = (printerId: number) => {
+    setSelectedPrinters(prev => {
+      const next = new Set(prev);
+      if (next.has(printerId)) {
+        next.delete(printerId);
+      } else {
+        next.add(printerId);
+      }
+      return next;
+    });
+  };
+
   const printerItems = overview?.map(p => ({
     printerId: p.printer_id,
     printerName: p.printer_name,
@@ -570,14 +615,37 @@ function SettingsSection({
                       })}
                     </div>
                   </div>
-                  <div className="flex gap-2">
-                    <Button type="button" variant="secondary" onClick={() => setShowAddType(false)}>
-                      Cancel
-                    </Button>
-                    <Button type="submit" disabled={!newTypeName.trim()}>
-                      Add Type
-                    </Button>
+                </div>
+                {/* Printer selection */}
+                <div className="mt-4">
+                  <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
+                  <div className="flex flex-wrap gap-2">
+                    {printers.map(p => (
+                      <button
+                        key={p.id}
+                        type="button"
+                        onClick={() => togglePrinterSelection(p.id)}
+                        className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
+                          selectedPrinters.has(p.id)
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                        }`}
+                      >
+                        {p.name}
+                      </button>
+                    ))}
                   </div>
+                  {selectedPrinters.size === 0 && (
+                    <p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
+                  )}
+                </div>
+                <div className="mt-4 flex justify-end gap-2">
+                  <Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
+                    Cancel
+                  </Button>
+                  <Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
+                    Add Type
+                  </Button>
                 </div>
               </form>
             </CardContent>
@@ -674,6 +742,10 @@ function SettingsSection({
               );
             }
 
+            const assignedPrinters = getAssignedPrinters(type.id);
+            const unassignedPrinters = getUnassignedPrinters(type.id);
+            const isExpanded = expandedType === type.id;
+
             return (
               <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
                 <div className="flex items-center gap-3">
@@ -692,6 +764,19 @@ function SettingsSection({
                       {formatIntervalLabel(type.default_interval_hours, intervalType)}
                     </div>
                   </div>
+                  <button
+                    onClick={() => setExpandedType(isExpanded ? null : type.id)}
+                    className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${
+                      assignedPrinters.length > 0
+                        ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
+                        : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
+                    }`}
+                    title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
+                  >
+                    <Printer className="w-3 h-3" />
+                    <span className="text-xs font-medium">{assignedPrinters.length}</span>
+                    <ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
+                  </button>
                   <button
                     onClick={() => startEditType(type)}
                     className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
@@ -709,6 +794,48 @@ function SettingsSection({
                     <Trash2 className="w-4 h-4" />
                   </button>
                 </div>
+
+                {/* Printer assignment management */}
+                {isExpanded && (
+                  <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
+                    <p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
+                    {assignedPrinters.length === 0 ? (
+                      <p className="text-xs text-orange-400">No printers assigned</p>
+                    ) : (
+                      <div className="flex flex-wrap gap-1 mb-2">
+                        {assignedPrinters.map(p => (
+                          <span
+                            key={p.printerId}
+                            className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white"
+                          >
+                            {p.printerName}
+                            <button
+                              onClick={() => p.itemId && onRemoveItem(p.itemId)}
+                              className="hover:text-red-400 ml-1"
+                              title="Remove from this printer"
+                            >
+                              ×
+                            </button>
+                          </span>
+                        ))}
+                      </div>
+                    )}
+                    {unassignedPrinters.length > 0 && (
+                      <div className="flex flex-wrap gap-1">
+                        <span className="text-xs text-bambu-gray mr-1">Add:</span>
+                        {unassignedPrinters.map(p => (
+                          <button
+                            key={p.id}
+                            onClick={() => onAssignType(p.id, type.id)}
+                            className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+                          >
+                            + {p.name}
+                          </button>
+                        ))}
+                      </div>
+                    )}
+                  </div>
+                )}
               </div>
             );
           })}
@@ -850,17 +977,8 @@ export function MaintenancePage() {
     },
   });
 
-  const addTypeMutation = useMutation({
-    mutationFn: api.createMaintenanceType,
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
-      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Maintenance type added');
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
+  // addTypeMutation removed - we now handle type creation with printer assignment
+  // directly in onAddType callback
 
   const updateTypeMutation = useMutation({
     mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
@@ -900,6 +1018,29 @@ export function MaintenancePage() {
     },
   });
 
+  const assignTypeMutation = useMutation({
+    mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
+      api.assignMaintenanceType(printerId, typeId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Printer assigned');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const removeItemMutation = useMutation({
+    mutationFn: api.removeMaintenanceItem,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Printer removed');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
   const handlePerform = (id: number) => {
     performMutation.mutate({ id });
   };
@@ -1002,9 +1143,21 @@ export function MaintenancePage() {
           onUpdateInterval={(id, data) =>
             updateMutation.mutate({ id, data })
           }
-          onAddType={(data) => addTypeMutation.mutate(data)}
+          onAddType={async (data, printerIds) => {
+            // Create the type first, then assign to selected printers
+            const newType = await api.createMaintenanceType(data);
+            // Assign to each selected printer
+            for (const printerId of printerIds) {
+              await api.assignMaintenanceType(printerId, newType.id);
+            }
+            queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
+            queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+            showToast('Maintenance type added');
+          }}
           onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
+          onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
+          onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
         />
       )}
     </div>

+ 66 - 17
frontend/src/pages/PrintersPage.tsx

@@ -596,6 +596,7 @@ function PrinterCard({
   const navigate = useNavigate();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [deleteArchives, setDeleteArchives] = useState(true);
   const [showEditModal, setShowEditModal] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
@@ -685,9 +686,11 @@ function PrinterCard({
   const shouldHide = hideIfDisconnected && isConnected === false;
 
   const deleteMutation = useMutation({
-    mutationFn: () => api.deletePrinter(printer.id),
+    mutationFn: (options: { deleteArchives: boolean }) =>
+      api.deletePrinter(printer.id, options.deleteArchives),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
     },
   });
 
@@ -919,17 +922,64 @@ function PrinterCard({
 
         {/* Delete Confirmation */}
         {showDeleteConfirm && (
-          <ConfirmModal
-            title="Delete Printer"
-            message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
-            confirmText="Delete"
-            variant="danger"
-            onConfirm={() => {
-              deleteMutation.mutate();
-              setShowDeleteConfirm(false);
-            }}
-            onCancel={() => setShowDeleteConfirm(false)}
-          />
+          <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+            <Card className="w-full max-w-md mx-4">
+              <CardContent>
+                <div className="flex items-start gap-3 mb-4">
+                  <div className="p-2 rounded-full bg-red-500/20">
+                    <AlertTriangle className="w-5 h-5 text-red-400" />
+                  </div>
+                  <div>
+                    <h3 className="text-lg font-semibold text-white">Delete Printer</h3>
+                    <p className="text-sm text-bambu-gray mt-1">
+                      Are you sure you want to delete "{printer.name}"? This will remove all connection settings.
+                    </p>
+                  </div>
+                </div>
+
+                <div className="bg-bambu-dark rounded-lg p-3 mb-4">
+                  <label className="flex items-start gap-3 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={deleteArchives}
+                      onChange={(e) => setDeleteArchives(e.target.checked)}
+                      className="mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                    />
+                    <div>
+                      <span className="text-sm text-white">Delete print archives</span>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {deleteArchives
+                          ? 'All print history for this printer will be permanently deleted.'
+                          : 'Print history will be kept but no longer associated with this printer.'}
+                      </p>
+                    </div>
+                  </label>
+                </div>
+
+                <div className="flex justify-end gap-2">
+                  <Button
+                    variant="secondary"
+                    onClick={() => {
+                      setShowDeleteConfirm(false);
+                      setDeleteArchives(true);
+                    }}
+                  >
+                    Cancel
+                  </Button>
+                  <Button
+                    variant="danger"
+                    onClick={() => {
+                      deleteMutation.mutate({ deleteArchives });
+                      setShowDeleteConfirm(false);
+                      setDeleteArchives(true);
+                    }}
+                  >
+                    Delete
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
         )}
 
         {/* Status */}
@@ -1039,11 +1089,10 @@ function PrinterCard({
 
             {/* Temperatures */}
             {status.temperatures && viewMode === 'expanded' && (() => {
-              // Determine heater states (target > 30°C means heater is actively set)
-              const nozzleHeating = (status.temperatures.nozzle_target || 0) > 30 ||
-                                    (status.temperatures.nozzle_2_target || 0) > 30;
-              const bedHeating = (status.temperatures.bed_target || 0) > 30;
-              const chamberHeating = (status.temperatures.chamber_target || 0) > 30;
+              // Use actual heater states from MQTT stream
+              const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
+              const bedHeating = status.temperatures.bed_heating || false;
+              const chamberHeating = status.temperatures.chamber_heating || false;
 
               return (
                 <div className="grid grid-cols-3 gap-3">

+ 1016 - 0
frontend/src/pages/ProjectDetailPage.tsx

@@ -0,0 +1,1016 @@
+import { useState, useRef } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  ArrowLeft,
+  Edit3,
+  Loader2,
+  Package,
+  Clock,
+  CheckCircle,
+  XCircle,
+  ListTodo,
+  Printer,
+  ChevronRight,
+  FileText,
+  Tag,
+  Calendar,
+  AlertTriangle,
+  Save,
+  X,
+  Paperclip,
+  Upload,
+  Download,
+  Trash2,
+  File,
+  DollarSign,
+  ClipboardList,
+  Plus,
+  History,
+  FolderTree,
+  Copy,
+  Layers,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { useToast } from '../contexts/ToastContext';
+import { RichTextEditor } from '../components/RichTextEditor';
+
+// Project edit modal (reused from ProjectsPage)
+import { ProjectModal } from './ProjectsPage';
+
+function formatDuration(hours: number): string {
+  if (hours < 1) {
+    return `${Math.round(hours * 60)}m`;
+  }
+  const h = Math.floor(hours);
+  const m = Math.round((hours - h) * 60);
+  return m > 0 ? `${h}h ${m}m` : `${h}h`;
+}
+
+function formatFilament(grams: number): string {
+  if (grams >= 1000) {
+    return `${(grams / 1000).toFixed(2)}kg`;
+  }
+  return `${Math.round(grams)}g`;
+}
+
+function StatusBadge({ status }: { status: string }) {
+  const colors = {
+    active: 'bg-bambu-green/20 text-bambu-green',
+    completed: 'bg-blue-500/20 text-blue-400',
+    archived: 'bg-bambu-gray/20 text-bambu-gray',
+  };
+  const color = colors[status as keyof typeof colors] || colors.active;
+
+  return (
+    <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
+      {status.charAt(0).toUpperCase() + status.slice(1)}
+    </span>
+  );
+}
+
+function StatCard({
+  icon: Icon,
+  label,
+  value,
+  subValue,
+  color = 'text-bambu-gray',
+}: {
+  icon: React.ElementType;
+  label: string;
+  value: string | number;
+  subValue?: string;
+  color?: string;
+}) {
+  return (
+    <Card>
+      <CardContent className="p-4">
+        <div className="flex items-center gap-3">
+          <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
+            <Icon className="w-5 h-5" />
+          </div>
+          <div>
+            <p className="text-sm text-bambu-gray">{label}</p>
+            <p className="text-xl font-semibold text-white">{value}</p>
+            {subValue && <p className="text-xs text-bambu-gray/70">{subValue}</p>}
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+function ArchiveGrid({ archives }: { archives: Archive[] }) {
+  if (archives.length === 0) {
+    return (
+      <div className="text-center py-8 text-bambu-gray">
+        <Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
+        <p>No prints in this project yet</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
+      {archives.map((archive) => (
+        <Link
+          key={archive.id}
+          to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
+          className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
+        >
+          {archive.thumbnail_path ? (
+            <img
+              src={`/api/v1/archives/${archive.id}/thumbnail`}
+              alt={archive.print_name || 'Print'}
+              className="w-full h-full object-cover"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              <Package className="w-8 h-8" />
+            </div>
+          )}
+
+          {/* Status overlay */}
+          {archive.status === 'failed' && (
+            <div className="absolute inset-0 bg-red-500/30 flex items-center justify-center">
+              <XCircle className="w-8 h-8 text-white" />
+            </div>
+          )}
+          {archive.status === 'completed' && (
+            <div className="absolute top-1 right-1">
+              <CheckCircle className="w-4 h-4 text-bambu-green" />
+            </div>
+          )}
+
+          {/* Name overlay on hover */}
+          <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
+            <p className="text-xs text-white truncate">{archive.print_name || 'Unknown'}</p>
+          </div>
+        </Link>
+      ))}
+    </div>
+  );
+}
+
+function PriorityBadge({ priority }: { priority: string }) {
+  const config = {
+    low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
+    normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
+    high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
+    urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
+  };
+  const { color, label } = config[priority as keyof typeof config] || config.normal;
+
+  return (
+    <span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>
+      {priority === 'urgent' && <AlertTriangle className="w-3 h-3" />}
+      {label}
+    </span>
+  );
+}
+
+function formatDate(dateString: string | null): string {
+  if (!dateString) return '';
+  const date = new Date(dateString);
+  return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
+  if (!dateString) return null;
+  const dueDate = new Date(dateString);
+  const now = new Date();
+  const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
+
+  if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
+  if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
+  if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
+  return { color: 'text-bambu-gray', label: `${diffDays} days left` };
+}
+
+export function ProjectDetailPage() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [editingNotes, setEditingNotes] = useState(false);
+  const [notesContent, setNotesContent] = useState('');
+  const [uploadingAttachment, setUploadingAttachment] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const projectId = parseInt(id || '0', 10);
+
+  const { data: project, isLoading: projectLoading, error: projectError } = useQuery({
+    queryKey: ['project', projectId],
+    queryFn: () => api.getProject(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: archives, isLoading: archivesLoading } = useQuery({
+    queryKey: ['project-archives', projectId],
+    queryFn: () => api.getProjectArchives(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: bomItems, isLoading: bomLoading } = useQuery({
+    queryKey: ['project-bom', projectId],
+    queryFn: () => api.getProjectBOM(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: timeline, isLoading: timelineLoading } = useQuery({
+    queryKey: ['project-timeline', projectId],
+    queryFn: () => api.getProjectTimeline(projectId, 20),
+    enabled: projectId > 0,
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      setShowEditModal(false);
+      setEditingNotes(false);
+      showToast('Project updated', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleStartEditNotes = () => {
+    setNotesContent(project?.notes || '');
+    setEditingNotes(true);
+  };
+
+  const handleSaveNotes = () => {
+    updateMutation.mutate({ notes: notesContent });
+  };
+
+  const handleCancelNotes = () => {
+    setEditingNotes(false);
+    setNotesContent('');
+  };
+
+  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    setUploadingAttachment(true);
+    try {
+      await api.uploadProjectAttachment(projectId, file);
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      showToast('Attachment uploaded', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    } finally {
+      setUploadingAttachment(false);
+      if (fileInputRef.current) {
+        fileInputRef.current.value = '';
+      }
+    }
+  };
+
+  const handleDeleteAttachment = async (filename: string) => {
+    if (!confirm('Delete this attachment?')) return;
+
+    try {
+      await api.deleteProjectAttachment(projectId, filename);
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      showToast('Attachment deleted', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    }
+  };
+
+  const formatFileSize = (bytes: number): string => {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+  };
+
+  // BOM handlers
+  const [newBomName, setNewBomName] = useState('');
+  const [newBomQty, setNewBomQty] = useState(1);
+
+  const createBomMutation = useMutation({
+    mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      setNewBomName('');
+      setNewBomQty(1);
+      showToast('BOM item added', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const updateBomMutation = useMutation({
+    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_printed: number } }) =>
+      api.updateBOMItem(projectId, itemId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const deleteBomMutation = useMutation({
+    mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      showToast('BOM item deleted', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const handleAddBomItem = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!newBomName.trim()) return;
+    createBomMutation.mutate({ name: newBomName.trim(), quantity_needed: newBomQty });
+  };
+
+  const handleIncrementPrinted = (item: BOMItem) => {
+    updateBomMutation.mutate({
+      itemId: item.id,
+      data: { quantity_printed: item.quantity_printed + 1 },
+    });
+  };
+
+  const handleDeleteBomItem = (itemId: number) => {
+    if (confirm('Delete this BOM item?')) {
+      deleteBomMutation.mutate(itemId);
+    }
+  };
+
+  // Template handlers
+  const createTemplateMutation = useMutation({
+    mutationFn: () => api.createTemplateFromProject(projectId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      showToast('Template created', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const formatTimelineDate = (timestamp: string) => {
+    const date = new Date(timestamp);
+    return date.toLocaleDateString(undefined, {
+      month: 'short',
+      day: 'numeric',
+      hour: '2-digit',
+      minute: '2-digit',
+    });
+  };
+
+  if (projectLoading) {
+    return (
+      <div className="flex items-center justify-center py-24">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  if (projectError || !project) {
+    return (
+      <div className="text-center py-24">
+        <p className="text-bambu-gray">
+          {projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
+        </p>
+        <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
+          Back to Projects
+        </Button>
+      </div>
+    );
+  }
+
+  const stats = project.stats;
+  const progressPercent = stats?.progress_percent ?? 0;
+  const successRate = stats && stats.total_archives > 0
+    ? ((stats.completed_prints / stats.total_archives) * 100).toFixed(0)
+    : null;
+
+  return (
+    <div className="space-y-6">
+      {/* Breadcrumb */}
+      <div className="flex items-center gap-2 text-sm text-bambu-gray">
+        <Link to="/projects" className="hover:text-white transition-colors">
+          Projects
+        </Link>
+        <ChevronRight className="w-4 h-4" />
+        <span className="text-white">{project.name}</span>
+      </div>
+
+      {/* Header */}
+      <div className="flex items-start justify-between">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/projects')}
+            className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
+          >
+            <ArrowLeft className="w-5 h-5 text-bambu-gray" />
+          </button>
+          <div className="flex items-center gap-3">
+            <div
+              className="w-4 h-4 rounded-full flex-shrink-0"
+              style={{ backgroundColor: project.color || '#6b7280' }}
+            />
+            <div>
+              <h1 className="text-2xl font-bold text-white">{project.name}</h1>
+              {project.description && (
+                <p className="text-bambu-gray mt-1">{project.description}</p>
+              )}
+            </div>
+          </div>
+          <StatusBadge status={project.status} />
+        </div>
+        <Button onClick={() => setShowEditModal(true)}>
+          <Edit3 className="w-4 h-4 mr-2" />
+          Edit
+        </Button>
+      </div>
+
+      {/* Progress bar (if target set) */}
+      {project.target_count && (
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center justify-between mb-2">
+              <span className="text-sm text-bambu-gray">Progress</span>
+              <span className="text-sm font-medium text-white">
+                {stats?.completed_prints || 0} / {project.target_count} prints
+              </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(progressPercent, 100)}%`,
+                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                }}
+              />
+            </div>
+            <div className="flex justify-between mt-1">
+              <span className="text-xs text-bambu-gray/70">
+                {progressPercent.toFixed(0)}% complete
+              </span>
+              {project.target_count - (stats?.completed_prints || 0) > 0 && (
+                <span className="text-xs text-bambu-gray/70">
+                  {project.target_count - (stats?.completed_prints || 0)} remaining
+                </span>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Stats grid */}
+      {stats && (
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+          <StatCard
+            icon={Package}
+            label="Total Prints"
+            value={stats.total_archives}
+            subValue={successRate ? `${successRate}% success rate` : undefined}
+            color="text-bambu-green"
+          />
+          <StatCard
+            icon={CheckCircle}
+            label="Completed"
+            value={stats.completed_prints}
+            subValue={stats.failed_prints > 0 ? `${stats.failed_prints} failed` : undefined}
+            color="text-blue-400"
+          />
+          <StatCard
+            icon={Clock}
+            label="Print Time"
+            value={formatDuration(stats.total_print_time_hours)}
+            color="text-yellow-400"
+          />
+          <StatCard
+            icon={Printer}
+            label="Filament Used"
+            value={formatFilament(stats.total_filament_grams)}
+            color="text-purple-400"
+          />
+        </div>
+      )}
+
+      {/* Cost tracking */}
+      {stats && (stats.estimated_cost > 0 || project.budget) && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
+              <DollarSign className="w-5 h-5" />
+              Cost Tracking
+            </h2>
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
+                <p className="text-lg font-semibold text-white">
+                  ${stats.estimated_cost.toFixed(2)}
+                </p>
+              </div>
+              {stats.total_energy_kwh > 0 && (
+                <div>
+                  <p className="text-xs text-bambu-gray uppercase">Energy</p>
+                  <p className="text-lg font-semibold text-white">
+                    {stats.total_energy_kwh.toFixed(2)} kWh
+                    {stats.total_energy_cost > 0 && (
+                      <span className="text-sm text-bambu-gray ml-1">
+                        (${stats.total_energy_cost.toFixed(2)})
+                      </span>
+                    )}
+                  </p>
+                </div>
+              )}
+              {project.budget && (
+                <>
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">Budget</p>
+                    <p className="text-lg font-semibold text-white">${project.budget.toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">Remaining</p>
+                    <p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
+                      ${(project.budget - stats.estimated_cost).toFixed(2)}
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Sub-projects */}
+      {project.children && project.children.length > 0 && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
+              <FolderTree className="w-5 h-5" />
+              Sub-projects ({project.children.length})
+            </h2>
+            <div className="space-y-2">
+              {project.children.map((child) => (
+                <Link
+                  key={child.id}
+                  to={`/projects/${child.id}`}
+                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                >
+                  <div className="flex items-center gap-3">
+                    <div
+                      className="w-3 h-3 rounded-full"
+                      style={{ backgroundColor: child.color || '#6b7280' }}
+                    />
+                    <span className="text-white">{child.name}</span>
+                    <span className={`text-xs px-2 py-0.5 rounded ${
+                      child.status === 'completed' ? 'bg-bambu-green/20 text-bambu-green' :
+                      child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
+                      'bg-blue-500/20 text-blue-400'
+                    }`}>
+                      {child.status}
+                    </span>
+                  </div>
+                  {child.progress_percent !== null && (
+                    <span className="text-sm text-bambu-gray">
+                      {child.progress_percent.toFixed(0)}%
+                    </span>
+                  )}
+                </Link>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Parent project link */}
+      {project.parent_id && project.parent_name && (
+        <div className="flex items-center gap-2 text-sm">
+          <Layers className="w-4 h-4 text-bambu-gray" />
+          <span className="text-bambu-gray">Part of:</span>
+          <Link
+            to={`/projects/${project.parent_id}`}
+            className="text-bambu-green hover:underline"
+          >
+            {project.parent_name}
+          </Link>
+        </div>
+      )}
+
+      {/* Meta info row - Tags, Due Date, Priority */}
+      {(project.tags || project.due_date || project.priority !== 'normal') && (
+        <div className="flex flex-wrap items-center gap-4">
+          {/* Priority */}
+          {project.priority && project.priority !== 'normal' && (
+            <div className="flex items-center gap-2">
+              <span className="text-xs text-bambu-gray uppercase">Priority:</span>
+              <PriorityBadge priority={project.priority} />
+            </div>
+          )}
+
+          {/* Due Date */}
+          {project.due_date && (
+            <div className="flex items-center gap-2">
+              <Calendar className="w-4 h-4 text-bambu-gray" />
+              <span className="text-sm text-white">{formatDate(project.due_date)}</span>
+              {getDueDateStatus(project.due_date) && (
+                <span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
+                  ({getDueDateStatus(project.due_date)!.label})
+                </span>
+              )}
+            </div>
+          )}
+
+          {/* Tags */}
+          {project.tags && (
+            <div className="flex items-center gap-2">
+              <Tag className="w-4 h-4 text-bambu-gray" />
+              <div className="flex flex-wrap gap-1">
+                {project.tags.split(',').map((tag, index) => (
+                  <span
+                    key={index}
+                    className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
+                  >
+                    {tag.trim()}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Notes section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <FileText className="w-5 h-5" />
+              Notes
+            </h2>
+            {!editingNotes ? (
+              <Button variant="secondary" size="sm" onClick={handleStartEditNotes}>
+                <Edit3 className="w-4 h-4 mr-1" />
+                Edit
+              </Button>
+            ) : (
+              <div className="flex gap-2">
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={handleCancelNotes}
+                  disabled={updateMutation.isPending}
+                >
+                  <X className="w-4 h-4 mr-1" />
+                  Cancel
+                </Button>
+                <Button
+                  size="sm"
+                  onClick={handleSaveNotes}
+                  disabled={updateMutation.isPending}
+                >
+                  {updateMutation.isPending ? (
+                    <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                  ) : (
+                    <Save className="w-4 h-4 mr-1" />
+                  )}
+                  Save
+                </Button>
+              </div>
+            )}
+          </div>
+
+          {editingNotes ? (
+            <RichTextEditor
+              content={notesContent}
+              onChange={setNotesContent}
+              placeholder="Add notes about this project..."
+            />
+          ) : project.notes ? (
+            <div
+              className="prose prose-invert prose-sm max-w-none"
+              dangerouslySetInnerHTML={{ __html: project.notes }}
+            />
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No notes yet. Click Edit to add notes.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Attachments section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <Paperclip className="w-5 h-5" />
+              Attachments ({project.attachments?.length || 0})
+            </h2>
+            <div>
+              <input
+                ref={fileInputRef}
+                type="file"
+                onChange={handleFileSelect}
+                className="hidden"
+              />
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => fileInputRef.current?.click()}
+                disabled={uploadingAttachment}
+              >
+                {uploadingAttachment ? (
+                  <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                ) : (
+                  <Upload className="w-4 h-4 mr-1" />
+                )}
+                Upload
+              </Button>
+            </div>
+          </div>
+
+          {project.attachments && project.attachments.length > 0 ? (
+            <div className="space-y-2">
+              {project.attachments.map((attachment) => (
+                <div
+                  key={attachment.filename}
+                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
+                >
+                  <div className="flex items-center gap-3 min-w-0">
+                    <File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
+                    <div className="min-w-0">
+                      <p className="text-sm text-white truncate">
+                        {attachment.original_name}
+                      </p>
+                      <p className="text-xs text-bambu-gray">
+                        {formatFileSize(attachment.size)}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1 flex-shrink-0">
+                    <a
+                      href={api.getProjectAttachmentUrl(projectId, attachment.filename)}
+                      download={attachment.original_name}
+                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                      title="Download"
+                    >
+                      <Download className="w-4 h-4" />
+                    </a>
+                    <button
+                      onClick={() => handleDeleteAttachment(attachment.filename)}
+                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-red-400"
+                      title="Delete"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No attachments. Upload STL files, reference images, or other documents.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* BOM Section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <ClipboardList className="w-5 h-5" />
+              Bill of Materials
+              {stats && stats.bom_total_items > 0 && (
+                <span className="text-sm font-normal text-bambu-gray">
+                  ({stats.bom_completed_items}/{stats.bom_total_items} complete)
+                </span>
+              )}
+            </h2>
+          </div>
+
+          {/* Add BOM item form */}
+          <form onSubmit={handleAddBomItem} className="flex gap-2 mb-4">
+            <input
+              type="text"
+              value={newBomName}
+              onChange={(e) => setNewBomName(e.target.value)}
+              className="flex-1 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder="Part name..."
+            />
+            <input
+              type="number"
+              value={newBomQty}
+              onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
+              className="w-20 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
+              min="1"
+            />
+            <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
+              {createBomMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Plus className="w-4 h-4" />
+              )}
+            </Button>
+          </form>
+
+          {bomLoading ? (
+            <div className="flex items-center justify-center py-4">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : bomItems && bomItems.length > 0 ? (
+            <div className="space-y-2">
+              {bomItems.map((item) => (
+                <div
+                  key={item.id}
+                  className={`flex items-center justify-between p-3 rounded-lg ${
+                    item.is_complete ? 'bg-bambu-green/10' : 'bg-bambu-dark'
+                  }`}
+                >
+                  <div className="flex items-center gap-3 min-w-0">
+                    <button
+                      onClick={() => handleIncrementPrinted(item)}
+                      disabled={updateBomMutation.isPending}
+                      className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
+                        item.is_complete
+                          ? 'bg-bambu-green border-bambu-green text-white'
+                          : 'border-bambu-gray hover:border-bambu-green'
+                      }`}
+                    >
+                      {item.is_complete && <CheckCircle className="w-4 h-4" />}
+                    </button>
+                    <div className="min-w-0">
+                      <p className={`text-sm ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
+                        {item.name}
+                      </p>
+                      <p className="text-xs text-bambu-gray">
+                        {item.quantity_printed} / {item.quantity_needed} printed
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <button
+                      onClick={() => handleIncrementPrinted(item)}
+                      disabled={updateBomMutation.isPending}
+                      className="px-2 py-1 text-xs bg-bambu-dark-tertiary rounded hover:bg-bambu-green/20 text-bambu-gray hover:text-white transition-colors"
+                    >
+                      +1
+                    </button>
+                    <button
+                      onClick={() => handleDeleteBomItem(item.id)}
+                      className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No parts in the bill of materials. Add parts to track what needs to be printed.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Timeline Section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <History className="w-5 h-5" />
+              Activity Timeline
+            </h2>
+          </div>
+
+          {timelineLoading ? (
+            <div className="flex items-center justify-center py-4">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : timeline && timeline.length > 0 ? (
+            <div className="space-y-3">
+              {timeline.slice(0, 10).map((event, index) => (
+                <div key={index} className="flex gap-3">
+                  <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
+                    event.event_type === 'print_completed' ? 'bg-bambu-green/20 text-bambu-green' :
+                    event.event_type === 'print_failed' ? 'bg-red-500/20 text-red-400' :
+                    event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :
+                    'bg-bambu-dark-tertiary text-bambu-gray'
+                  }`}>
+                    {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
+                    {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
+                    {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
+                    {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
+                    {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-white">{event.title}</p>
+                    {event.description && (
+                      <p className="text-xs text-bambu-gray truncate">{event.description}</p>
+                    )}
+                    <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No activity yet.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Template action */}
+      {!project.is_template && (
+        <div className="flex justify-end">
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => createTemplateMutation.mutate()}
+            disabled={createTemplateMutation.isPending}
+          >
+            {createTemplateMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin mr-2" />
+            ) : (
+              <Copy className="w-4 h-4 mr-2" />
+            )}
+            Save as Template
+          </Button>
+        </div>
+      )}
+
+      {/* Queue section */}
+      {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center justify-between mb-3">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <ListTodo className="w-5 h-5" />
+                Queue
+              </h2>
+              <Link
+                to={`/queue?project=${projectId}`}
+                className="text-sm text-bambu-green hover:underline"
+              >
+                View all
+              </Link>
+            </div>
+            <div className="flex items-center gap-4 text-sm">
+              {stats.in_progress_prints > 0 && (
+                <span className="text-yellow-400">
+                  {stats.in_progress_prints} printing
+                </span>
+              )}
+              {stats.queued_prints > 0 && (
+                <span className="text-bambu-gray">
+                  {stats.queued_prints} queued
+                </span>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Archives section */}
+      <div>
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Package className="w-5 h-5" />
+            Prints ({archives?.length || 0})
+          </h2>
+        </div>
+        {archivesLoading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+          </div>
+        ) : (
+          <ArchiveGrid archives={archives || []} />
+        )}
+      </div>
+
+      {/* Edit Modal */}
+      {showEditModal && (
+        <ProjectModal
+          project={{
+            ...project,
+            archive_count: stats?.total_archives || 0,
+            queue_count: stats?.queued_prints || 0,
+            progress_percent: stats?.progress_percent || null,
+            archives: [],
+          }}
+          onClose={() => setShowEditModal(false)}
+          onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
+          isLoading={updateMutation.isPending}
+        />
+      )}
+    </div>
+  );
+}

+ 242 - 93
frontend/src/pages/ProjectsPage.tsx

@@ -1,4 +1,5 @@
 import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   FolderKanban,
@@ -9,10 +10,14 @@ import {
   Archive,
   ListTodo,
   Package,
+  Clock,
+  CheckCircle2,
+  AlertTriangle,
+  ChevronRight,
+  MoreVertical,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
-import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
@@ -36,12 +41,15 @@ interface ProjectModalProps {
   isLoading: boolean;
 }
 
-function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
+export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
   const [name, setName] = useState(project?.name || '');
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
+  const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
+  const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
+  const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
@@ -50,6 +58,9 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
       description: description.trim() || undefined,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+      tags: tags.trim() || undefined,
+      due_date: dueDate || undefined,
+      priority,
       ...(project && { status }),
     });
   };
@@ -124,6 +135,50 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
             />
           </div>
 
+          {/* Tags */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Tags (comma-separated)
+            </label>
+            <input
+              type="text"
+              value={tags}
+              onChange={(e) => setTags(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="e.g., voron, functional, gift"
+            />
+          </div>
+
+          {/* Due Date and Priority in a row */}
+          <div className="grid grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Due Date
+              </label>
+              <input
+                type="date"
+                value={dueDate}
+                onChange={(e) => setDueDate(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Priority
+              </label>
+              <select
+                value={priority}
+                onChange={(e) => setPriority(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+              >
+                <option value="low">Low</option>
+                <option value="normal">Normal</option>
+                <option value="high">High</option>
+                <option value="urgent">Urgent</option>
+              </select>
+            </div>
+          </div>
+
           {project && (
             <div>
               <label className="block text-sm font-medium text-white mb-1">
@@ -172,74 +227,121 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
   const progressPercent = project.progress_percent ?? 0;
   const isCompleted = project.status === 'completed';
   const isArchived = project.status === 'archived';
+  const [showActions, setShowActions] = useState(false);
+
+  // Status icon and color
+  const getStatusConfig = () => {
+    if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
+    if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
+    if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
+    return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
+  };
+  const statusConfig = getStatusConfig();
 
   return (
-    <Card className="hover:border-bambu-gray/30 transition-colors cursor-pointer" onClick={onClick}>
-      <CardContent className="p-4">
-        <div className="flex items-start justify-between mb-3">
-          <div className="flex items-center gap-3">
-            <div
-              className="w-3 h-3 rounded-full flex-shrink-0"
-              style={{ backgroundColor: project.color || '#6b7280' }}
-            />
-            <div>
-              <h3 className="font-medium text-white">{project.name}</h3>
+    <div
+      className="group relative bg-bambu-card rounded-xl border border-bambu-dark-tertiary hover:border-bambu-gray/40 transition-all cursor-pointer overflow-hidden"
+      onClick={onClick}
+    >
+      {/* Color accent bar */}
+      <div
+        className="absolute top-0 left-0 w-1 h-full"
+        style={{ backgroundColor: project.color || '#6b7280' }}
+      />
+
+      <div className="p-5 pl-6">
+        {/* Header */}
+        <div className="flex items-start justify-between mb-4">
+          <div className="flex items-center gap-3 min-w-0 flex-1">
+            <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
+              <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
+            </div>
+            <div className="min-w-0 flex-1">
+              <div className="flex items-center gap-2">
+                <h3 className="font-semibold text-white truncate">{project.name}</h3>
+                {isCompleted && (
+                  <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
+                    Done
+                  </span>
+                )}
+                {isArchived && (
+                  <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
+                    Archived
+                  </span>
+                )}
+              </div>
               {project.description && (
-                <p className="text-sm text-bambu-gray/70 mt-0.5 line-clamp-1">
+                <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                   {project.description}
                 </p>
               )}
             </div>
           </div>
-          <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
-            {isCompleted && (
-              <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded">
-                Completed
-              </span>
-            )}
-            {isArchived && (
-              <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded">
-                Archived
-              </span>
+
+          {/* Actions menu */}
+          <div className="relative" onClick={(e) => e.stopPropagation()}>
+            <button
+              className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
+              onClick={() => setShowActions(!showActions)}
+            >
+              <MoreVertical className="w-4 h-4" />
+            </button>
+            {showActions && (
+              <>
+                <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
+                <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
+                  <button
+                    className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                    onClick={() => { onEdit(); setShowActions(false); }}
+                  >
+                    <Edit3 className="w-4 h-4" />
+                    Edit
+                  </button>
+                  <button
+                    className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
+                    onClick={() => { onDelete(); setShowActions(false); }}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                    Delete
+                  </button>
+                </div>
+              </>
             )}
-            <Button variant="ghost" size="sm" onClick={onEdit} className="p-1">
-              <Edit3 className="w-4 h-4" />
-            </Button>
-            <Button variant="ghost" size="sm" onClick={onDelete} className="p-1 text-red-400 hover:text-red-300">
-              <Trash2 className="w-4 h-4" />
-            </Button>
           </div>
         </div>
 
-        {/* Progress bar */}
-        {project.target_count && (
-          <div className="mb-3">
-            <div className="flex justify-between text-xs text-bambu-gray mb-1">
-              <span>{project.archive_count} / {project.target_count} prints</span>
-              <span>{progressPercent.toFixed(0)}%</span>
+        {/* Progress section */}
+        {project.target_count ? (
+          <div className="mb-4">
+            <div className="flex items-center justify-between text-xs mb-2">
+              <span className="text-bambu-gray">Progress</span>
+              <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                {project.archive_count} / {project.target_count}
+              </span>
             </div>
             <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
               <div
-                className="h-full transition-all duration-300"
+                className="h-full transition-all duration-500 ease-out rounded-full"
                 style={{
                   width: `${Math.min(progressPercent, 100)}%`,
                   backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
                 }}
               />
             </div>
+            <div className="text-right text-xs text-bambu-gray/60 mt-1">
+              {progressPercent.toFixed(0)}% complete
+            </div>
           </div>
-        )}
+        ) : null}
 
-        {/* Archive thumbnails */}
+        {/* Archive thumbnails grid */}
         {project.archives && project.archives.length > 0 && (
-          <div className="mb-3">
-            <div className="flex gap-2">
-              {project.archives.slice(0, 5).map((archive) => (
-                <a
+          <div className="mb-4">
+            <div className="flex gap-1.5">
+              {project.archives.slice(0, 4).map((archive) => (
+                <div
                   key={archive.id}
-                  href={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
-                  onClick={(e) => e.stopPropagation()}
-                  className="relative w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                  className="relative w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary"
                   title={archive.print_name || 'Unknown'}
                 >
                   {archive.thumbnail_path ? (
@@ -249,43 +351,49 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                       className="w-full h-full object-cover"
                     />
                   ) : (
-                    <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-                      <Package className="w-6 h-6" />
+                    <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
+                      <Package className="w-5 h-5" />
                     </div>
                   )}
                   {archive.status === 'failed' && (
-                    <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
-                      <span className="text-white text-xs font-bold">✗</span>
+                    <div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
+                      <AlertTriangle className="w-4 h-4 text-white" />
                     </div>
                   )}
-                </a>
+                </div>
               ))}
-              {project.archive_count > 5 && (
-                <div className="w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-sm text-bambu-gray border border-bambu-dark-tertiary">
-                  +{project.archive_count - 5}
+              {project.archive_count > 4 && (
+                <div className="w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-xs text-bambu-gray border border-bambu-dark-tertiary">
+                  +{project.archive_count - 4}
                 </div>
               )}
             </div>
           </div>
         )}
 
-        {/* Stats */}
-        <div className="flex items-center gap-4 text-sm text-bambu-gray">
-          <div className="flex items-center gap-1" title="Archives">
-            <Archive className="w-4 h-4" />
-            <span>{project.archive_count}</span>
-          </div>
-          <div className="flex items-center gap-1" title="Queued">
-            <ListTodo className="w-4 h-4" />
-            <span>{project.queue_count}</span>
+        {/* Stats footer */}
+        <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
+          <div className="flex items-center gap-4 text-xs text-bambu-gray">
+            <div className="flex items-center gap-1.5" title="Completed prints">
+              <Archive className="w-3.5 h-3.5" />
+              <span>{project.archive_count}</span>
+            </div>
+            {project.queue_count > 0 && (
+              <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
+                <ListTodo className="w-3.5 h-3.5" />
+                <span>{project.queue_count}</span>
+              </div>
+            )}
           </div>
+          <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
         </div>
-      </CardContent>
-    </Card>
+      </div>
+    </div>
   );
 }
 
 export function ProjectsPage() {
+  const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [showModal, setShowModal] = useState(false);
@@ -350,8 +458,8 @@ export function ProjectsPage() {
   };
 
   const handleClick = (project: ProjectListItem) => {
-    // Open edit modal when clicking on card
-    handleEdit(project);
+    // Navigate to project detail page
+    navigate(`/projects/${project.id}`);
   };
 
   const handleDeleteClick = (id: number) => {
@@ -364,54 +472,95 @@ export function ProjectsPage() {
     }
   };
 
+  // Count projects by status for filter badges
+  const projectCounts = projects?.reduce((acc, p) => {
+    acc[p.status] = (acc[p.status] || 0) + 1;
+    acc.all = (acc.all || 0) + 1;
+    return acc;
+  }, {} as Record<string, number>) || {};
+
   return (
     <div className="space-y-6">
       {/* Header */}
-      <div className="flex items-center justify-between">
-        <div className="flex items-center gap-3">
-          <FolderKanban className="w-6 h-6 text-bambu-green" />
-          <h1 className="text-2xl font-bold text-white">Projects</h1>
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <div className="p-2 bg-bambu-green/10 rounded-lg">
+              <FolderKanban className="w-6 h-6 text-bambu-green" />
+            </div>
+            Projects
+          </h1>
+          <p className="text-sm text-bambu-gray mt-1 ml-14">
+            Organize and track your 3D printing projects
+          </p>
         </div>
-        <Button onClick={() => setShowModal(true)}>
+        <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
           <Plus className="w-4 h-4 mr-2" />
           New Project
         </Button>
       </div>
 
-      {/* Filters */}
-      <div className="flex gap-2">
-        {['active', 'completed', 'archived', 'all'].map((status) => (
+      {/* Filter tabs */}
+      <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
+        {[
+          { key: 'active', label: 'Active', icon: Clock },
+          { key: 'completed', label: 'Completed', icon: CheckCircle2 },
+          { key: 'archived', label: 'Archived', icon: Archive },
+          { key: 'all', label: 'All', icon: FolderKanban },
+        ].map(({ key, label, icon: Icon }) => (
           <button
-            key={status}
-            onClick={() => setStatusFilter(status)}
-            className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
-              statusFilter === status
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
+            key={key}
+            onClick={() => setStatusFilter(key)}
+            className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
+              statusFilter === key
+                ? 'bg-bambu-card text-white shadow-sm'
+                : 'text-bambu-gray hover:text-white'
             }`}
           >
-            {status.charAt(0).toUpperCase() + status.slice(1)}
+            <Icon className="w-4 h-4" />
+            <span>{label}</span>
+            {projectCounts[key] > 0 && (
+              <span className={`text-xs px-1.5 py-0.5 rounded-full ${
+                statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}>
+                {projectCounts[key]}
+              </span>
+            )}
           </button>
         ))}
       </div>
 
       {/* Content */}
       {isLoading ? (
-        <div className="flex items-center justify-center py-12">
-          <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+        <div className="flex items-center justify-center py-20">
+          <div className="flex flex-col items-center gap-3">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+            <p className="text-sm text-bambu-gray">Loading projects...</p>
+          </div>
         </div>
       ) : projects?.length === 0 ? (
-        <Card>
-          <CardContent className="py-12 text-center">
-            <FolderKanban className="w-12 h-12 text-bambu-gray/50 mx-auto mb-4" />
-            <p className="text-bambu-gray">No projects found</p>
-            <p className="text-bambu-gray/70 text-sm mt-1">
-              Create a project to group related prints together
-            </p>
-          </CardContent>
-        </Card>
+        <div className="flex flex-col items-center justify-center py-20 px-4">
+          <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
+            <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
+          </div>
+          <h3 className="text-lg font-medium text-white mb-2">
+            {statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
+          </h3>
+          <p className="text-bambu-gray text-center max-w-md mb-6">
+            {statusFilter === 'all'
+              ? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
+              : `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
+            }
+          </p>
+          {statusFilter === 'all' && (
+            <Button onClick={() => setShowModal(true)}>
+              <Plus className="w-4 h-4 mr-2" />
+              Create Your First Project
+            </Button>
+          )}
+        </div>
       ) : (
-        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
           {projects?.map((project) => (
             <ProjectCard
               key={project.id}

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


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


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


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


+ 2 - 2
static/index.html

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

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