diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 05d5512e7..18e9fcbd2 100644 --- a/.pre-commit-config.yaml +++ b/.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 diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index d7eea1672..0f9725dc0 100644 --- a/backend/app/api/routes/maintenance.py +++ b/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 diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 2d55dfeea..059019d89 100644 --- a/backend/app/api/routes/printers.py +++ b/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) diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index ac360360e..0d4337cc6 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -1,21 +1,35 @@ import logging -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +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.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 ( - ProjectCreate, - ProjectUpdate, - ProjectResponse, - ProjectListResponse, - ProjectStats, + ArchivePreview, BatchAddArchives, BatchAddQueueItems, - ArchivePreview, + BOMItemCreate, + BOMItemResponse, + BOMItemUpdate, + ProjectChildPreview, + ProjectCreate, + ProjectListResponse, + ProjectResponse, + ProjectStats, + 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,11 +212,26 @@ 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() @@ -205,12 +246,172 @@ 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=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) + + 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=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] diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 881c83b54..63a5b6054 100644 --- a/backend/app/core/database.py +++ b/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" - )) + 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 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 diff --git a/backend/app/models/project.py b/backend/app/models/project.py index f41a4a0e1..a93223bc5 100644 --- a/backend/app/models/project.py +++ b/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 diff --git a/backend/app/models/project_bom.py b/backend/app/models/project_bom.py new file mode 100644 index 000000000..89fdd58c5 --- /dev/null +++ b/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 diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 384b3fca2..a7b25be04 100644 --- a/backend/app/schemas/project.py +++ b/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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51754a2c1..796ee1144 100644 --- a/frontend/src/App.tsx +++ b/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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 66ec775eb..04fba0ce1 100644 --- a/frontend/src/api/client.ts +++ b/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 | null; } // API Key types @@ -1122,8 +1213,11 @@ export const api = { method: 'PATCH', body: JSON.stringify(data), }), - deletePrinter: (id: number) => - request(`/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(`/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(`/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(`/projects/${projectId}/bom`), + createBOMItem: (projectId: number, data: BOMItemCreate) => + request(`/projects/${projectId}/bom`, { + method: 'POST', + body: JSON.stringify(data), + }), + updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) => + request(`/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('/projects/templates/'), + createTemplateFromProject: (projectId: number) => + request(`/projects/${projectId}/create-template`, { method: 'POST' }), + createProjectFromTemplate: (templateId: number, name?: string) => + request(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, { + method: 'POST', + }), + + // Timeline + getProjectTimeline: (projectId: number, limit = 50) => + request(`/projects/${projectId}/timeline?limit=${limit}`), + // API Keys getAPIKeys: () => request('/api-keys/'), createAPIKey: (data: APIKeyCreate) => diff --git a/frontend/src/pages/MaintenancePage.tsx b/frontend/src/pages/MaintenancePage.tsx index 3cb559a36..402ab4974 100644 --- a/frontend/src/pages/MaintenancePage.tsx +++ b/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(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>(new Set()); + const [expandedType, setExpandedType] = useState(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(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({ })} -
- - +
+ {/* Printer selection */} +
+ +
+ {printers.map(p => ( + + ))}
+ {selectedPrinters.size === 0 && ( +

Select at least one printer

+ )} +
+
+ +
@@ -674,6 +742,10 @@ function SettingsSection({ ); } + const assignedPrinters = getAssignedPrinters(type.id); + const unassignedPrinters = getUnassignedPrinters(type.id); + const isExpanded = expandedType === type.id; + return (
@@ -692,6 +764,19 @@ function SettingsSection({ {formatIntervalLabel(type.default_interval_hours, intervalType)}
+ + + {/* Printer assignment management */} + {isExpanded && ( +
+

Assigned to printers:

+ {assignedPrinters.length === 0 ? ( +

No printers assigned

+ ) : ( +
+ {assignedPrinters.map(p => ( + + {p.printerName} + + + ))} +
+ )} + {unassignedPrinters.length > 0 && ( +
+ Add: + {unassignedPrinters.map(p => ( + + ))} +
+ )} +
+ )} ); })} @@ -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)} /> )} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 540751f21..b859d8806 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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 && ( - { - deleteMutation.mutate(); - setShowDeleteConfirm(false); - }} - onCancel={() => setShowDeleteConfirm(false)} - /> +
+ + +
+
+ +
+
+

Delete Printer

+

+ Are you sure you want to delete "{printer.name}"? This will remove all connection settings. +

+
+
+ +
+ +
+ +
+ + +
+
+
+
)} {/* 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 (
diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx new file mode 100644 index 000000000..d9b578fa6 --- /dev/null +++ b/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 ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +function StatCard({ + icon: Icon, + label, + value, + subValue, + color = 'text-bambu-gray', +}: { + icon: React.ElementType; + label: string; + value: string | number; + subValue?: string; + color?: string; +}) { + return ( + + +
+
+ +
+
+

{label}

+

{value}

+ {subValue &&

{subValue}

} +
+
+
+
+ ); +} + +function ArchiveGrid({ archives }: { archives: Archive[] }) { + if (archives.length === 0) { + return ( +
+ +

No prints in this project yet

+
+ ); + } + + return ( +
+ {archives.map((archive) => ( + + {archive.thumbnail_path ? ( + {archive.print_name + ) : ( +
+ +
+ )} + + {/* Status overlay */} + {archive.status === 'failed' && ( +
+ +
+ )} + {archive.status === 'completed' && ( +
+ +
+ )} + + {/* Name overlay on hover */} +
+

{archive.print_name || 'Unknown'}

+
+ + ))} +
+ ); +} + +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 ( + + {priority === 'urgent' && } + {label} + + ); +} + +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(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) => { + 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 ( +
+ +
+ ); + } + + if (projectError || !project) { + return ( +
+

+ {projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'} +

+ +
+ ); + } + + 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 ( +
+ {/* Breadcrumb */} +
+ + Projects + + + {project.name} +
+ + {/* Header */} +
+
+ +
+
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+
+ +
+ +
+ + {/* Progress bar (if target set) */} + {project.target_count && ( + + +
+ Progress + + {stats?.completed_prints || 0} / {project.target_count} prints + +
+
+
= 100 ? '#22c55e' : project.color || '#6b7280', + }} + /> +
+
+ + {progressPercent.toFixed(0)}% complete + + {project.target_count - (stats?.completed_prints || 0) > 0 && ( + + {project.target_count - (stats?.completed_prints || 0)} remaining + + )} +
+ + + )} + + {/* Stats grid */} + {stats && ( +
+ + 0 ? `${stats.failed_prints} failed` : undefined} + color="text-blue-400" + /> + + +
+ )} + + {/* Cost tracking */} + {stats && (stats.estimated_cost > 0 || project.budget) && ( + + +

+ + Cost Tracking +

+
+
+

Filament Cost

+

+ ${stats.estimated_cost.toFixed(2)} +

+
+ {stats.total_energy_kwh > 0 && ( +
+

Energy

+

+ {stats.total_energy_kwh.toFixed(2)} kWh + {stats.total_energy_cost > 0 && ( + + (${stats.total_energy_cost.toFixed(2)}) + + )} +

+
+ )} + {project.budget && ( + <> +
+

Budget

+

${project.budget.toFixed(2)}

+
+
+

Remaining

+

= 0 ? 'text-bambu-green' : 'text-red-400'}`}> + ${(project.budget - stats.estimated_cost).toFixed(2)} +

+
+ + )} +
+
+
+ )} + + {/* Sub-projects */} + {project.children && project.children.length > 0 && ( + + +

+ + Sub-projects ({project.children.length}) +

+
+ {project.children.map((child) => ( + +
+
+ {child.name} + + {child.status} + +
+ {child.progress_percent !== null && ( + + {child.progress_percent.toFixed(0)}% + + )} + + ))} +
+ + + )} + + {/* Parent project link */} + {project.parent_id && project.parent_name && ( +
+ + Part of: + + {project.parent_name} + +
+ )} + + {/* Meta info row - Tags, Due Date, Priority */} + {(project.tags || project.due_date || project.priority !== 'normal') && ( +
+ {/* Priority */} + {project.priority && project.priority !== 'normal' && ( +
+ Priority: + +
+ )} + + {/* Due Date */} + {project.due_date && ( +
+ + {formatDate(project.due_date)} + {getDueDateStatus(project.due_date) && ( + + ({getDueDateStatus(project.due_date)!.label}) + + )} +
+ )} + + {/* Tags */} + {project.tags && ( +
+ +
+ {project.tags.split(',').map((tag, index) => ( + + {tag.trim()} + + ))} +
+
+ )} +
+ )} + + {/* Notes section */} + + +
+

+ + Notes +

+ {!editingNotes ? ( + + ) : ( +
+ + +
+ )} +
+ + {editingNotes ? ( + + ) : project.notes ? ( +
+ ) : ( +

+ No notes yet. Click Edit to add notes. +

+ )} + + + + {/* Attachments section */} + + +
+

+ + Attachments ({project.attachments?.length || 0}) +

+
+ + +
+
+ + {project.attachments && project.attachments.length > 0 ? ( +
+ {project.attachments.map((attachment) => ( +
+
+ +
+

+ {attachment.original_name} +

+

+ {formatFileSize(attachment.size)} +

+
+
+
+ + + + +
+
+ ))} +
+ ) : ( +

+ No attachments. Upload STL files, reference images, or other documents. +

+ )} +
+
+ + {/* BOM Section */} + + +
+

+ + Bill of Materials + {stats && stats.bom_total_items > 0 && ( + + ({stats.bom_completed_items}/{stats.bom_total_items} complete) + + )} +

+
+ + {/* Add BOM item form */} +
+ 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..." + /> + 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" + /> + +
+ + {bomLoading ? ( +
+ +
+ ) : bomItems && bomItems.length > 0 ? ( +
+ {bomItems.map((item) => ( +
+
+ +
+

+ {item.name} +

+

+ {item.quantity_printed} / {item.quantity_needed} printed +

+
+
+
+ + +
+
+ ))} +
+ ) : ( +

+ No parts in the bill of materials. Add parts to track what needs to be printed. +

+ )} +
+
+ + {/* Timeline Section */} + + +
+

+ + Activity Timeline +

+
+ + {timelineLoading ? ( +
+ +
+ ) : timeline && timeline.length > 0 ? ( +
+ {timeline.slice(0, 10).map((event, index) => ( +
+
+ {event.event_type === 'print_completed' && } + {event.event_type === 'print_failed' && } + {event.event_type === 'print_started' && } + {event.event_type === 'queued' && } + {event.event_type === 'project_created' && } +
+
+

{event.title}

+ {event.description && ( +

{event.description}

+ )} +

{formatTimelineDate(event.timestamp)}

+
+
+ ))} +
+ ) : ( +

+ No activity yet. +

+ )} +
+
+ + {/* Template action */} + {!project.is_template && ( +
+ +
+ )} + + {/* Queue section */} + {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && ( + + +
+

+ + Queue +

+ + View all + +
+
+ {stats.in_progress_prints > 0 && ( + + {stats.in_progress_prints} printing + + )} + {stats.queued_prints > 0 && ( + + {stats.queued_prints} queued + + )} +
+
+
+ )} + + {/* Archives section */} +
+
+

+ + Prints ({archives?.length || 0}) +

+
+ {archivesLoading ? ( +
+ +
+ ) : ( + + )} +
+ + {/* Edit Modal */} + {showEditModal && ( + setShowEditModal(false)} + onSave={(data) => updateMutation.mutate(data as ProjectUpdate)} + isLoading={updateMutation.isPending} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index 9d7161709..4f487d2bc 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/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 />
+ {/* Tags */} +
+ + 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" + /> +
+ + {/* Due Date and Priority in a row */} +
+
+ + 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" + /> +
+
+ + +
+
+ {project && (