- Project page enhancements and bug fixes

- Add filament color swatches to project cards showing colors from assigned archives
  - Add "Hide done" toggle to filter completed BOM items
  - Include projects in backup/restore (with BOM items and attachments)
  - Add file type validation for project attachments
  - Enhance project card design with gradients, shadows, and glow effects
  - Improve layout spacing on project list and detail pages
  - Replace browser confirm dialogs with styled confirmation modals
  - Fix attachment uploads not persisting (SQLAlchemy JSON column mutation)
This commit is contained in:
maziggy
2025-12-28 12:04:33 +01:00
parent 8acc91d3cd
commit db5cb86d3a
16 changed files with 9477 additions and 8926 deletions
+8
View File
@@ -8,13 +8,21 @@ All notable changes to Bambuddy will be documented in this file.
- **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
- **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
- **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
- **Project filament colors** - Project cards now display filament color swatches from assigned archives.
- **BOM filter** - Hide completed BOM items with "Hide done" toggle on project detail page.
- **Projects in backup/restore** - Projects, BOM items, and attachments now included in database backup/restore.
- **Attachment file validation** - File type validation for project attachments (images, documents, 3D files, archives, scripts, configs).
### Changed
- **GitHub issue template** - Added mandatory printer firmware version field and LAN-only mode checkbox for better bug reports.
- **Docker compose** - Clearer comments explaining `network_mode: host` requirement for printer discovery and camera streaming.
- **Project card design** - Enhanced visual polish with gradients, shadows, and glow effects on hover.
- **Project page layout** - Improved spacing and padding on project list and detail pages.
- **Delete confirmations** - Replaced browser confirm dialogs with styled confirmation modals.
### Fixed
- **Notification module** - Fixed bug where notifications were sent even when printer was offline.
- **Attachment uploads** - Fixed file attachments not persisting due to SQLAlchemy JSON column mutation detection.
## [0.1.5] - 2025-12-19
-8
View File
@@ -97,14 +97,6 @@
- Print time accuracy stats
- File manager for printer storage
### 🎛️ Printer Control
- AMS/AMS-HT temperature & humidity monitoring
- Chamber temperature & light control
- Speed profiles & fan controls
- AI detection modules (spaghetti, first layer)
- Automated calibration (bed level, vibration)
- Dual nozzle support
</td>
</tr>
</table>
+129 -32
View File
@@ -98,7 +98,7 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
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(
func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
"completed"
),
).where(ProjectBOMItem.project_id == project_id)
@@ -183,6 +183,8 @@ async def list_projects(
print_name=a.print_name,
thumbnail_path=a.thumbnail_path,
status=a.status,
filament_type=a.filament_type,
filament_color=a.filament_color,
)
for a in archives
]
@@ -342,9 +344,11 @@ async def create_project_from_template(
project_id=project.id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=0,
quantity_acquired=0,
unit_price=item.unit_price,
sourcing_url=item.sourcing_url,
stl_filename=item.stl_filename,
notes=item.notes,
remarks=item.remarks,
sort_order=item.sort_order,
)
db.add(new_item)
@@ -691,6 +695,63 @@ def get_project_attachments_dir(project_id: int) -> Path:
return base_dir / "projects" / str(project_id) / "attachments"
# Allowed file extensions for attachments
ALLOWED_ATTACHMENT_EXTENSIONS = {
# Images
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
".bmp",
".ico",
# Documents
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".txt",
".rtf",
".csv",
".md",
# 3D/CAD files
".stl",
".obj",
".3mf",
".step",
".stp",
".iges",
".igs",
".f3d",
".scad",
# Archives
".zip",
".rar",
".7z",
".tar",
".gz",
# Code/scripts (for Klipper macros, scripts, etc.)
".py",
".sh",
".cfg",
".conf",
".gcode",
".ini",
# Other common formats
".json",
".xml",
".yaml",
".yml",
}
@router.post("/{project_id}/attachments")
async def upload_attachment(
project_id: int,
@@ -698,19 +759,28 @@ async def upload_attachment(
db: AsyncSession = Depends(get_db),
):
"""Upload an attachment to a project."""
logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
# 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")
# Validate file extension
original_name = file.filename or "unknown"
ext = os.path.splitext(original_name)[1].lower()
if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
)
# 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
@@ -719,30 +789,43 @@ async def upload_attachment(
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
logger.info(f"=== FILE SAVED: {file_path}, size: {len(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(),
}
)
attachments = list(project.attachments or [])
new_attachment = {
"filename": unique_filename,
"original_name": original_name,
"size": len(content),
"uploaded_at": datetime.now().isoformat(),
}
attachments.append(new_attachment)
# Simple ORM update
project.attachments = attachments
db.add(project) # Explicitly add to session
logger.info(f"=== BEFORE COMMIT: {len(attachments)} attachments ===")
await db.flush()
await db.refresh(project)
await db.commit()
logger.info("=== AFTER COMMIT ===")
# Verify by re-querying
result = await db.execute(select(Project).where(Project.id == project_id))
fresh_project = result.scalar_one()
logger.info(f"=== VERIFIED: {len(fresh_project.attachments or [])} attachments ===")
return {
"status": "success",
"filename": unique_filename,
"original_name": original_name,
"attachments": project.attachments,
"attachments": fresh_project.attachments,
}
@@ -854,13 +937,15 @@ async def list_bom_items(
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
quantity_acquired=item.quantity_acquired,
unit_price=item.unit_price,
sourcing_url=item.sourcing_url,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
remarks=item.remarks,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
is_complete=item.quantity_acquired >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
@@ -891,9 +976,11 @@ async def create_bom_item(
project_id=project_id,
name=data.name,
quantity_needed=data.quantity_needed,
unit_price=data.unit_price,
sourcing_url=data.sourcing_url,
archive_id=data.archive_id,
stl_filename=data.stl_filename,
notes=data.notes,
remarks=data.remarks,
sort_order=max_order + 1,
)
db.add(item)
@@ -911,13 +998,15 @@ async def create_bom_item(
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
quantity_acquired=item.quantity_acquired,
unit_price=item.unit_price,
sourcing_url=item.sourcing_url,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
remarks=item.remarks,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
is_complete=item.quantity_acquired >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
@@ -946,14 +1035,18 @@ async def update_bom_item(
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.quantity_acquired is not None:
item.quantity_acquired = data.quantity_acquired
if data.unit_price is not None:
item.unit_price = data.unit_price if data.unit_price != 0 else None
if data.sourcing_url is not None:
item.sourcing_url = data.sourcing_url if data.sourcing_url else None
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
if data.remarks is not None:
item.remarks = data.remarks if data.remarks else None
await db.flush()
await db.refresh(item)
@@ -969,13 +1062,15 @@ async def update_bom_item(
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
quantity_acquired=item.quantity_acquired,
unit_price=item.unit_price,
sourcing_url=item.sourcing_url,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
remarks=item.remarks,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
is_complete=item.quantity_acquired >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
@@ -1041,9 +1136,11 @@ async def create_template_from_project(
project_id=template.id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=0,
quantity_acquired=0,
unit_price=item.unit_price,
sourcing_url=item.sourcing_url,
stl_filename=item.stl_filename,
notes=item.notes,
remarks=item.remarks,
sort_order=item.sort_order,
)
db.add(new_item)
+281 -116
View File
@@ -3,28 +3,28 @@ import json
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, UploadFile, File, Query
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
from backend.app.models.settings import Settings
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.printer import Printer
from backend.app.models.filament import Filament
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.archive import PrintArchive
from backend.app.models.external_link import ExternalLink
from backend.app.models.filament import Filament
from backend.app.models.maintenance import MaintenanceType
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.project_bom import ProjectBOMItem
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
from backend.app.services.spoolman import init_spoolman_client
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -63,7 +63,14 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
for setting in db_settings:
if setting.key in settings_dict:
# Parse the value based on the expected type
if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates", "telemetry_enabled"]:
if setting.key in [
"auto_archive",
"save_thumbnails",
"capture_finish_photo",
"spoolman_enabled",
"check_updates",
"telemetry_enabled",
]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
settings_dict[setting.key] = float(setting.value)
@@ -173,6 +180,7 @@ async def export_backup(
include_filaments: bool = Query(False, description="Include filament inventory"),
include_maintenance: bool = Query(False, description="Include maintenance types and records"),
include_archives: bool = Query(False, description="Include print archive metadata"),
include_projects: bool = Query(False, description="Include projects with BOM items"),
include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
):
"""Export selected data as JSON backup."""
@@ -195,27 +203,29 @@ async def export_backup(
providers = result.scalars().all()
backup["notification_providers"] = []
for p in providers:
backup["notification_providers"].append({
"name": p.name,
"provider_type": p.provider_type,
"enabled": p.enabled,
"config": json.loads(p.config) if isinstance(p.config, str) else p.config,
"on_print_start": p.on_print_start,
"on_print_complete": p.on_print_complete,
"on_print_failed": p.on_print_failed,
"on_print_stopped": p.on_print_stopped,
"on_print_progress": p.on_print_progress,
"on_printer_offline": p.on_printer_offline,
"on_printer_error": p.on_printer_error,
"on_filament_low": p.on_filament_low,
"on_maintenance_due": p.on_maintenance_due,
"quiet_hours_enabled": p.quiet_hours_enabled,
"quiet_hours_start": p.quiet_hours_start,
"quiet_hours_end": p.quiet_hours_end,
"daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
"daily_digest_time": getattr(p, 'daily_digest_time', None),
"printer_id": getattr(p, 'printer_id', None),
})
backup["notification_providers"].append(
{
"name": p.name,
"provider_type": p.provider_type,
"enabled": p.enabled,
"config": json.loads(p.config) if isinstance(p.config, str) else p.config,
"on_print_start": p.on_print_start,
"on_print_complete": p.on_print_complete,
"on_print_failed": p.on_print_failed,
"on_print_stopped": p.on_print_stopped,
"on_print_progress": p.on_print_progress,
"on_printer_offline": p.on_printer_offline,
"on_printer_error": p.on_printer_error,
"on_filament_low": p.on_filament_low,
"on_maintenance_due": p.on_maintenance_due,
"quiet_hours_enabled": p.quiet_hours_enabled,
"quiet_hours_start": p.quiet_hours_start,
"quiet_hours_end": p.quiet_hours_end,
"daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
"daily_digest_time": getattr(p, "daily_digest_time", None),
"printer_id": getattr(p, "printer_id", None),
}
)
backup["included"].append("notification_providers")
# Notification templates
@@ -224,13 +234,15 @@ async def export_backup(
templates = result.scalars().all()
backup["notification_templates"] = []
for t in templates:
backup["notification_templates"].append({
"event_type": t.event_type,
"name": t.name,
"title_template": t.title_template,
"body_template": t.body_template,
"is_default": t.is_default,
})
backup["notification_templates"].append(
{
"event_type": t.event_type,
"name": t.name,
"title_template": t.title_template,
"body_template": t.body_template,
"is_default": t.is_default,
}
)
backup["included"].append("notification_templates")
# Smart plugs
@@ -239,25 +251,27 @@ async def export_backup(
plugs = result.scalars().all()
backup["smart_plugs"] = []
for plug in plugs:
backup["smart_plugs"].append({
"name": plug.name,
"ip_address": plug.ip_address,
"printer_id": plug.printer_id,
"enabled": plug.enabled,
"auto_on": plug.auto_on,
"auto_off": plug.auto_off,
"off_delay_mode": plug.off_delay_mode,
"off_delay_minutes": plug.off_delay_minutes,
"off_temp_threshold": plug.off_temp_threshold,
"username": plug.username,
"password": plug.password,
"power_alert_enabled": plug.power_alert_enabled,
"power_alert_high": plug.power_alert_high,
"power_alert_low": plug.power_alert_low,
"schedule_enabled": plug.schedule_enabled,
"schedule_on_time": plug.schedule_on_time,
"schedule_off_time": plug.schedule_off_time,
})
backup["smart_plugs"].append(
{
"name": plug.name,
"ip_address": plug.ip_address,
"printer_id": plug.printer_id,
"enabled": plug.enabled,
"auto_on": plug.auto_on,
"auto_off": plug.auto_off,
"off_delay_mode": plug.off_delay_mode,
"off_delay_minutes": plug.off_delay_minutes,
"off_temp_threshold": plug.off_temp_threshold,
"username": plug.username,
"password": plug.password,
"power_alert_enabled": plug.power_alert_enabled,
"power_alert_high": plug.power_alert_high,
"power_alert_low": plug.power_alert_low,
"schedule_enabled": plug.schedule_enabled,
"schedule_on_time": plug.schedule_on_time,
"schedule_off_time": plug.schedule_off_time,
}
)
backup["included"].append("smart_plugs")
# External links
@@ -312,21 +326,23 @@ async def export_backup(
filaments = result.scalars().all()
backup["filaments"] = []
for f in filaments:
backup["filaments"].append({
"name": f.name,
"type": f.type,
"brand": f.brand,
"color": f.color,
"color_hex": f.color_hex,
"cost_per_kg": f.cost_per_kg,
"spool_weight_g": f.spool_weight_g,
"currency": f.currency,
"density": f.density,
"print_temp_min": f.print_temp_min,
"print_temp_max": f.print_temp_max,
"bed_temp_min": f.bed_temp_min,
"bed_temp_max": f.bed_temp_max,
})
backup["filaments"].append(
{
"name": f.name,
"type": f.type,
"brand": f.brand,
"color": f.color,
"color_hex": f.color_hex,
"cost_per_kg": f.cost_per_kg,
"spool_weight_g": f.spool_weight_g,
"currency": f.currency,
"density": f.density,
"print_temp_min": f.print_temp_min,
"print_temp_max": f.print_temp_max,
"bed_temp_min": f.bed_temp_min,
"bed_temp_max": f.bed_temp_max,
}
)
backup["included"].append("filaments")
# Maintenance types and records
@@ -336,14 +352,16 @@ async def export_backup(
types = result.scalars().all()
backup["maintenance_types"] = []
for mt in types:
backup["maintenance_types"].append({
"name": mt.name,
"description": mt.description,
"default_interval_hours": mt.default_interval_hours,
"interval_type": mt.interval_type,
"icon": mt.icon,
"is_system": mt.is_system,
})
backup["maintenance_types"].append(
{
"name": mt.name,
"description": mt.description,
"default_interval_hours": mt.default_interval_hours,
"interval_type": mt.interval_type,
"icon": mt.icon,
"is_system": mt.is_system,
}
)
backup["included"].append("maintenance_types")
# Collect files for ZIP (icons + archives)
@@ -365,9 +383,17 @@ async def export_backup(
backup["archives"] = []
base_dir = app_settings.base_dir
# Build project ID to name mapping for archive export
project_id_to_name: dict[int, str] = {}
if include_projects:
proj_result = await db.execute(select(Project))
for proj in proj_result.scalars().all():
project_id_to_name[proj.id] = proj.name
for a in archives:
archive_data = {
"filename": a.filename,
"project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
"file_size": a.file_size,
"content_hash": a.content_hash,
"print_name": a.print_name,
@@ -432,10 +458,61 @@ async def export_backup(
backup["archives"].append(archive_data)
backup["included"].append("archives")
# Projects with BOM items
if include_projects:
result = await db.execute(select(Project))
projects = result.scalars().all()
backup["projects"] = []
for p in projects:
# Get BOM items for this project
bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
bom_items = bom_result.scalars().all()
project_data = {
"name": p.name,
"description": p.description,
"color": p.color,
"status": p.status,
"target_count": p.target_count,
"notes": p.notes,
"tags": p.tags,
"due_date": p.due_date.isoformat() if p.due_date else None,
"priority": p.priority,
"budget": p.budget,
"is_template": p.is_template,
"bom_items": [
{
"name": item.name,
"quantity_needed": item.quantity_needed,
"quantity_acquired": item.quantity_acquired,
"unit_price": item.unit_price,
"sourcing_url": item.sourcing_url,
"stl_filename": item.stl_filename,
"remarks": item.remarks,
"sort_order": item.sort_order,
}
for item in bom_items
],
}
# Include attachment files for ZIP
if p.attachments:
project_data["attachments"] = p.attachments
attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
for att in p.attachments:
att_path = attachments_dir / att.get("filename", "")
if att_path.exists():
zip_path = f"projects/{p.id}/attachments/{att['filename']}"
backup_files.append((zip_path, att_path))
backup["projects"].append(project_data)
backup["included"].append("projects")
# If there are files to include (icons or archives), create ZIP file
if backup_files:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
# Add backup.json
zf.writestr("backup.json", json.dumps(backup, indent=2))
@@ -454,7 +531,7 @@ async def export_backup(
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={filename}"}
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# Otherwise return JSON
@@ -462,7 +539,7 @@ async def export_backup(
content=backup,
headers={
"Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
}
},
)
@@ -479,27 +556,27 @@ async def import_backup(
files_restored = 0
# Check if it's a ZIP file
if file.filename and file.filename.endswith('.zip'):
if file.filename and file.filename.endswith(".zip"):
try:
zip_buffer = io.BytesIO(content)
with zipfile.ZipFile(zip_buffer, 'r') as zf:
with zipfile.ZipFile(zip_buffer, "r") as zf:
# Extract backup.json
if 'backup.json' not in zf.namelist():
if "backup.json" not in zf.namelist():
return {"success": False, "message": "Invalid ZIP: missing backup.json"}
backup_content = zf.read('backup.json')
backup_content = zf.read("backup.json")
backup = json.loads(backup_content.decode("utf-8"))
# Extract all other files to base_dir
for zip_path in zf.namelist():
if zip_path == 'backup.json':
if zip_path == "backup.json":
continue
# Ensure path is safe (no path traversal)
if '..' in zip_path or zip_path.startswith('/'):
if ".." in zip_path or zip_path.startswith("/"):
continue
target_path = base_dir / zip_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
with zf.open(zip_path) as src, open(target_path, "wb") as dst:
dst.write(src.read())
files_restored += 1
except zipfile.BadZipFile:
@@ -520,6 +597,7 @@ async def import_backup(
"printers": 0,
"filaments": 0,
"maintenance_types": 0,
"projects": 0,
}
skipped = {
"settings": 0,
@@ -531,6 +609,7 @@ async def import_backup(
"filaments": 0,
"maintenance_types": 0,
"archives": 0,
"projects": 0,
}
skipped_details = {
"notification_providers": [],
@@ -540,6 +619,7 @@ async def import_backup(
"filaments": [],
"maintenance_types": [],
"archives": [],
"projects": [],
}
# Restore settings (always overwrites)
@@ -616,9 +696,7 @@ async def import_backup(
if "notification_templates" in backup:
for template_data in backup["notification_templates"]:
result = await db.execute(
select(NotificationTemplate).where(
NotificationTemplate.event_type == template_data["event_type"]
)
select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
)
existing = result.scalar_one_or_none()
if existing:
@@ -641,9 +719,7 @@ async def import_backup(
# Restore smart plugs (skip or overwrite duplicates by IP)
if "smart_plugs" in backup:
for plug_data in backup["smart_plugs"]:
result = await db.execute(
select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
)
result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
@@ -697,10 +773,7 @@ async def import_backup(
for link_data in backup["external_links"]:
result = await db.execute(
select(ExternalLink).where(
ExternalLink.name == link_data["name"],
ExternalLink.url == link_data["url"]
)
select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
)
existing = result.scalar_one_or_none()
if existing:
@@ -728,9 +801,7 @@ async def import_backup(
# Restore printers (skip or overwrite duplicates by serial_number)
if "printers" in backup:
for printer_data in backup["printers"]:
result = await db.execute(
select(Printer).where(Printer.serial_number == printer_data["serial_number"])
)
result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
@@ -805,7 +876,9 @@ async def import_backup(
restored["filaments"] += 1
else:
skipped["filaments"] += 1
skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
skipped_details["filaments"].append(
f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
)
else:
filament = Filament(
name=filament_data["name"],
@@ -828,9 +901,7 @@ async def import_backup(
# Restore maintenance types (skip or overwrite duplicates by name)
if "maintenance_types" in backup:
for mt_data in backup["maintenance_types"]:
result = await db.execute(
select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
)
result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
@@ -861,9 +932,7 @@ async def import_backup(
# Skip if no content_hash or already exists
content_hash = archive_data.get("content_hash")
if content_hash:
result = await db.execute(
select(PrintArchive).where(PrintArchive.content_hash == content_hash)
)
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
existing = result.scalar_one_or_none()
if existing:
skipped["archives"] += 1
@@ -907,15 +976,111 @@ async def import_backup(
db.add(archive)
restored["archives"] = restored.get("archives", 0) + 1
# Restore projects (skip or overwrite duplicates by name)
if "projects" in backup:
for project_data in backup["projects"]:
result = await db.execute(select(Project).where(Project.name == project_data["name"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
# Update existing project
existing.description = project_data.get("description")
existing.color = project_data.get("color")
existing.status = project_data.get("status", "active")
existing.target_count = project_data.get("target_count")
existing.notes = project_data.get("notes")
existing.tags = project_data.get("tags")
existing.priority = project_data.get("priority", "normal")
existing.budget = project_data.get("budget")
existing.is_template = project_data.get("is_template", False)
existing.attachments = project_data.get("attachments")
if project_data.get("due_date"):
existing.due_date = datetime.fromisoformat(project_data["due_date"])
# Delete existing BOM items and re-add
await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
for bom_data in project_data.get("bom_items", []):
bom_item = ProjectBOMItem(
project_id=existing.id,
name=bom_data["name"],
quantity_needed=bom_data.get("quantity_needed", 1),
quantity_acquired=bom_data.get("quantity_acquired", 0),
unit_price=bom_data.get("unit_price"),
sourcing_url=bom_data.get("sourcing_url"),
stl_filename=bom_data.get("stl_filename"),
remarks=bom_data.get("remarks"),
sort_order=bom_data.get("sort_order", 0),
)
db.add(bom_item)
restored["projects"] += 1
else:
skipped["projects"] += 1
skipped_details["projects"].append(project_data["name"])
else:
# Create new project
project = Project(
name=project_data["name"],
description=project_data.get("description"),
color=project_data.get("color"),
status=project_data.get("status", "active"),
target_count=project_data.get("target_count"),
notes=project_data.get("notes"),
tags=project_data.get("tags"),
priority=project_data.get("priority", "normal"),
budget=project_data.get("budget"),
is_template=project_data.get("is_template", False),
attachments=project_data.get("attachments"),
)
if project_data.get("due_date"):
project.due_date = datetime.fromisoformat(project_data["due_date"])
db.add(project)
await db.flush() # Get the project ID
# Add BOM items
for bom_data in project_data.get("bom_items", []):
bom_item = ProjectBOMItem(
project_id=project.id,
name=bom_data["name"],
quantity_needed=bom_data.get("quantity_needed", 1),
quantity_acquired=bom_data.get("quantity_acquired", 0),
unit_price=bom_data.get("unit_price"),
sourcing_url=bom_data.get("sourcing_url"),
stl_filename=bom_data.get("stl_filename"),
remarks=bom_data.get("remarks"),
sort_order=bom_data.get("sort_order", 0),
)
db.add(bom_item)
restored["projects"] += 1
# Link archives to projects by name (after both are restored)
if "archives" in backup and "projects" in backup:
# Build project name to ID mapping
proj_result = await db.execute(select(Project))
project_name_to_id: dict[str, int] = {}
for proj in proj_result.scalars().all():
project_name_to_id[proj.name] = proj.id
# Update archives with project_id
for archive_data in backup["archives"]:
project_name = archive_data.get("project_name")
if project_name and project_name in project_name_to_id:
content_hash = archive_data.get("content_hash")
if content_hash:
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
archive = result.scalar_one_or_none()
if archive:
archive.project_id = project_name_to_id[project_name]
await db.commit()
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
# This ensures connections are re-established after restore, even if printers were skipped
if "printers" in backup:
# Need fresh query after commit to get proper IDs for newly created printers
result = await db.execute(
select(Printer).where(Printer.is_active == True)
)
result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
active_printers = result.scalars().all()
for printer in active_printers:
# This will disconnect existing connection (if any) and reconnect
+24
View File
@@ -335,6 +335,30 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Rename quantity_printed to quantity_acquired in project_bom_items
try:
await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
except Exception:
pass
# Migration: Add unit_price column to project_bom_items
try:
await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
except Exception:
pass
# Migration: Add sourcing_url column to project_bom_items
try:
await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
except Exception:
pass
# Migration: Rename notes to remarks in project_bom_items
try:
await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+15 -7
View File
@@ -1,13 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, Float, 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."""
"""Bill of Materials item for a project.
Tracks sourced/purchased parts (hardware, electronics, screws, etc.)
that need to be acquired for a project.
"""
__tablename__ = "project_bom_items"
@@ -15,16 +19,20 @@ class ProjectBOMItem(Base):
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)
quantity_acquired: Mapped[int] = mapped_column(Integer, default=0)
# Optional link to archive that prints this part
# Sourcing information
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
sourcing_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Optional link to archive (for reference)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
# Reference to attachment filename (STL file)
# Reference to attachment filename
stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Notes about this part
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Remarks about this part
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
# Sort order
sort_order: Mapped[int] = mapped_column(Integer, default=0)
+14 -6
View File
@@ -100,6 +100,8 @@ class ArchivePreview(BaseModel):
print_name: str | None
thumbnail_path: str | None
status: str
filament_type: str | None = None
filament_color: str | None = None
class ProjectListResponse(BaseModel):
@@ -135,15 +137,17 @@ class BatchAddQueueItems(BaseModel):
queue_item_ids: list[int]
# Phase 7: BOM Schemas
# Phase 7: BOM Schemas - Tracks sourced/purchased parts
class BOMItemCreate(BaseModel):
"""Schema for creating a BOM item."""
name: str
quantity_needed: int = 1
unit_price: float | None = None
sourcing_url: str | None = None
archive_id: int | None = None
stl_filename: str | None = None
notes: str | None = None
remarks: str | None = None
class BOMItemUpdate(BaseModel):
@@ -151,10 +155,12 @@ class BOMItemUpdate(BaseModel):
name: str | None = None
quantity_needed: int | None = None
quantity_printed: int | None = None
quantity_acquired: int | None = None
unit_price: float | None = None
sourcing_url: str | None = None
archive_id: int | None = None
stl_filename: str | None = None
notes: str | None = None
remarks: str | None = None
class BOMItemResponse(BaseModel):
@@ -164,11 +170,13 @@ class BOMItemResponse(BaseModel):
project_id: int
name: str
quantity_needed: int
quantity_printed: int
quantity_acquired: int
unit_price: float | None
sourcing_url: str | None
archive_id: int | None
archive_name: str | None = None
stl_filename: str | None
notes: str | None
remarks: str | None
sort_order: int
is_complete: bool = False
created_at: datetime
+16 -6
View File
@@ -384,6 +384,8 @@ export interface ArchivePreview {
print_name: string | null;
thumbnail_path: string | null;
status: string;
filament_type: string | null;
filament_color: string | null;
}
export interface ProjectListItem {
@@ -427,17 +429,19 @@ export interface ProjectUpdate {
parent_id?: number;
}
// BOM Types
// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
export interface BOMItem {
id: number;
project_id: number;
name: string;
quantity_needed: number;
quantity_printed: number;
quantity_acquired: number;
unit_price: number | null;
sourcing_url: string | null;
archive_id: number | null;
archive_name: string | null;
stl_filename: string | null;
notes: string | null;
remarks: string | null;
sort_order: number;
is_complete: boolean;
created_at: string;
@@ -447,18 +451,22 @@ export interface BOMItem {
export interface BOMItemCreate {
name: string;
quantity_needed?: number;
unit_price?: number;
sourcing_url?: string;
archive_id?: number;
stl_filename?: string;
notes?: string;
remarks?: string;
}
export interface BOMItemUpdate {
name?: string;
quantity_needed?: number;
quantity_printed?: number;
quantity_acquired?: number;
unit_price?: number;
sourcing_url?: string;
archive_id?: number;
stl_filename?: string;
notes?: string;
remarks?: string;
}
// Timeline Types
@@ -1572,10 +1580,12 @@ export const api = {
if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
}
const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
+17 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -47,6 +47,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
default: true,
description: 'Tasmota plug configurations',
},
{
id: 'external_links',
labelKey: 'backup.categories.externalLinks',
defaultLabel: 'External Links',
icon: <Link className="w-4 h-4" />,
default: true,
description: 'Sidebar links to external services',
},
{
id: 'printers',
labelKey: 'backup.categories.printers',
@@ -79,6 +87,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
default: false,
description: 'All print data + files (3MF, thumbnails, photos)',
},
{
id: 'projects',
labelKey: 'backup.categories.projects',
defaultLabel: 'Projects',
icon: <FolderKanban className="w-4 h-4" />,
default: false,
description: 'Projects, BOM items, and attachments',
},
];
interface BackupModalProps {
+246 -93
View File
@@ -23,13 +23,13 @@ import {
Download,
Trash2,
File,
DollarSign,
ClipboardList,
Plus,
History,
FolderTree,
Copy,
Layers,
ExternalLink,
ShoppingCart,
} from 'lucide-react';
import { api } from '../api/client';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
@@ -37,6 +37,7 @@ import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { RichTextEditor } from '../components/RichTextEditor';
import { ConfirmModal } from '../components/ConfirmModal';
// Project edit modal (reused from ProjectsPage)
import { ProjectModal } from './ProjectsPage';
@@ -227,6 +228,13 @@ export function ProjectDetailPage() {
enabled: projectId > 0,
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const currency = settings?.currency || '$';
const updateMutation = useMutation({
mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
onSuccess: () => {
@@ -261,9 +269,9 @@ export function ProjectDetailPage() {
setUploadingAttachment(true);
try {
await api.uploadProjectAttachment(projectId, file);
const result = await api.uploadProjectAttachment(projectId, file);
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
showToast('Attachment uploaded', 'success');
showToast(`Uploaded: ${result.original_name}`, 'success');
} catch (error) {
showToast((error as Error).message, 'error');
} finally {
@@ -274,16 +282,22 @@ export function ProjectDetailPage() {
}
};
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 handleDeleteAttachment = (filename: string, originalName: string) => {
setConfirmModal({
isOpen: true,
title: 'Delete Attachment',
message: `Are you sure you want to delete "${originalName}"?`,
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, isOpen: false }));
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 => {
@@ -295,6 +309,19 @@ export function ProjectDetailPage() {
// BOM handlers
const [newBomName, setNewBomName] = useState('');
const [newBomQty, setNewBomQty] = useState(1);
const [newBomPrice, setNewBomPrice] = useState('');
const [newBomUrl, setNewBomUrl] = useState('');
const [newBomRemarks, setNewBomRemarks] = useState('');
const [showBomForm, setShowBomForm] = useState(false);
const [hideBomCompleted, setHideBomCompleted] = useState(false);
// Confirm modal state
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
const createBomMutation = useMutation({
mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
@@ -303,13 +330,17 @@ export function ProjectDetailPage() {
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
setNewBomName('');
setNewBomQty(1);
showToast('BOM item added', 'success');
setNewBomPrice('');
setNewBomUrl('');
setNewBomRemarks('');
setShowBomForm(false);
showToast('Part added', 'success');
},
onError: (error: Error) => showToast(error.message, 'error'),
});
const updateBomMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_printed: number } }) =>
mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
api.updateBOMItem(projectId, itemId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
@@ -323,7 +354,7 @@ export function ProjectDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
showToast('BOM item deleted', 'success');
showToast('Part removed', 'success');
},
onError: (error: Error) => showToast(error.message, 'error'),
});
@@ -331,20 +362,33 @@ export function ProjectDetailPage() {
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 },
createBomMutation.mutate({
name: newBomName.trim(),
quantity_needed: newBomQty,
unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
sourcing_url: newBomUrl.trim() || undefined,
remarks: newBomRemarks.trim() || undefined,
});
};
const handleDeleteBomItem = (itemId: number) => {
if (confirm('Delete this BOM item?')) {
deleteBomMutation.mutate(itemId);
}
const handleToggleAcquired = (item: BOMItem) => {
const newQty = item.is_complete ? 0 : item.quantity_needed;
updateBomMutation.mutate({
itemId: item.id,
data: { quantity_acquired: newQty },
});
};
const handleDeleteBomItem = (itemId: number, itemName: string) => {
setConfirmModal({
isOpen: true,
title: 'Delete Part',
message: `Are you sure you want to delete "${itemName}"?`,
onConfirm: () => {
setConfirmModal(prev => ({ ...prev, isOpen: false }));
deleteBomMutation.mutate(itemId);
},
});
};
// Template handlers
@@ -395,7 +439,7 @@ export function ProjectDetailPage() {
: null;
return (
<div className="space-y-6">
<div className="p-4 md:p-8 space-y-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Link to="/projects" className="hover:text-white transition-colors">
@@ -503,15 +547,14 @@ export function ProjectDetailPage() {
{stats && (stats.estimated_cost > 0 || project.budget) && (
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
<DollarSign className="w-5 h-5" />
<h2 className="text-lg font-semibold text-white mb-3">
Cost Tracking
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
<p className="text-lg font-semibold text-white">
${stats.estimated_cost.toFixed(2)}
{currency}{stats.estimated_cost.toFixed(2)}
</p>
</div>
{stats.total_energy_kwh > 0 && (
@@ -521,7 +564,7 @@ export function ProjectDetailPage() {
{stats.total_energy_kwh.toFixed(2)} kWh
{stats.total_energy_cost > 0 && (
<span className="text-sm text-bambu-gray ml-1">
(${stats.total_energy_cost.toFixed(2)})
({currency}{stats.total_energy_cost.toFixed(2)})
</span>
)}
</p>
@@ -531,12 +574,12 @@ export function ProjectDetailPage() {
<>
<div>
<p className="text-xs text-bambu-gray uppercase">Budget</p>
<p className="text-lg font-semibold text-white">${project.budget.toFixed(2)}</p>
<p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-bambu-gray uppercase">Remaining</p>
<p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
${(project.budget - stats.estimated_cost).toFixed(2)}
{currency}{(project.budget - stats.estimated_cost).toFixed(2)}
</p>
</div>
</>
@@ -734,6 +777,10 @@ export function ProjectDetailPage() {
</div>
</div>
<p className="text-xs text-bambu-gray mb-3">
Upload any file: images (PNG, JPG), PDFs, STL files, or documents.
</p>
{project.attachments && project.attachments.length > 0 ? (
<div className="space-y-2">
{project.attachments.map((attachment) => (
@@ -762,7 +809,7 @@ export function ProjectDetailPage() {
<Download className="w-4 h-4" />
</a>
<button
onClick={() => handleDeleteAttachment(attachment.filename)}
onClick={() => handleDeleteAttachment(attachment.filename, attachment.original_name)}
className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-red-400"
title="Delete"
>
@@ -774,51 +821,106 @@ export function ProjectDetailPage() {
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No attachments. Upload STL files, reference images, or other documents.
No attachments yet. Click Upload to add files.
</p>
)}
</CardContent>
</Card>
{/* BOM Section */}
{/* BOM Section - Parts to source/purchase */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<ClipboardList className="w-5 h-5" />
<ShoppingCart className="w-5 h-5" />
Bill of Materials
{stats && stats.bom_total_items > 0 && (
<span className="text-sm font-normal text-bambu-gray">
({stats.bom_completed_items}/{stats.bom_total_items} complete)
({stats.bom_completed_items}/{stats.bom_total_items} acquired)
</span>
)}
</h2>
<div className="flex items-center gap-2">
{bomItems && bomItems.some(item => item.is_complete) && (
<button
onClick={() => setHideBomCompleted(!hideBomCompleted)}
className={`text-xs px-2 py-1 rounded transition-colors ${
hideBomCompleted
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
}`}
>
{hideBomCompleted ? 'Show all' : 'Hide done'}
</button>
)}
{!showBomForm && (
<Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
<Plus className="w-4 h-4 mr-1" />
Add Part
</Button>
)}
</div>
</div>
{/* Add BOM item form */}
<form onSubmit={handleAddBomItem} className="flex gap-2 mb-4">
<input
type="text"
value={newBomName}
onChange={(e) => setNewBomName(e.target.value)}
className="flex-1 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Part name..."
/>
<input
type="number"
value={newBomQty}
onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
className="w-20 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
min="1"
/>
<Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
{createBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
</Button>
</form>
{showBomForm && (
<form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
value={newBomName}
onChange={(e) => setNewBomName(e.target.value)}
className="bg-bambu-dark-secondary 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 (e.g., M3x8 screws)"
autoFocus
/>
<div className="flex gap-2">
<input
type="number"
value={newBomQty}
onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
min="1"
placeholder="Qty"
/>
<input
type="number"
step="0.01"
value={newBomPrice}
onChange={(e) => setNewBomPrice(e.target.value)}
className="flex-1 bg-bambu-dark-secondary 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={`Price (${currency})`}
/>
</div>
</div>
<input
type="url"
value={newBomUrl}
onChange={(e) => setNewBomUrl(e.target.value)}
className="w-full bg-bambu-dark-secondary 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="Sourcing URL (optional)"
/>
<input
type="text"
value={newBomRemarks}
onChange={(e) => setNewBomRemarks(e.target.value)}
className="w-full bg-bambu-dark-secondary 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="Remarks (optional)"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
{createBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Add Part'
)}
</Button>
</div>
</form>
)}
{bomLoading ? (
<div className="flex items-center justify-center py-4">
@@ -826,55 +928,94 @@ export function ProjectDetailPage() {
</div>
) : bomItems && bomItems.length > 0 ? (
<div className="space-y-2">
{bomItems.map((item) => (
{bomItems
.filter(item => !hideBomCompleted || !item.is_complete)
.map((item) => (
<div
key={item.id}
className={`flex items-center justify-between p-3 rounded-lg ${
className={`p-3 rounded-lg transition-colors ${
item.is_complete ? 'bg-bambu-green/10' : 'bg-bambu-dark'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-start gap-3">
<button
onClick={() => handleIncrementPrinted(item)}
onClick={() => handleToggleAcquired(item)}
disabled={updateBomMutation.isPending}
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
item.is_complete
? 'bg-bambu-green border-bambu-green text-white'
: 'border-bambu-gray hover:border-bambu-green'
}`}
>
{item.is_complete && <CheckCircle className="w-4 h-4" />}
{item.is_complete && <CheckCircle className="w-3 h-3" />}
</button>
<div className="min-w-0">
<p className={`text-sm ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
{item.name}
</p>
<p className="text-xs text-bambu-gray">
{item.quantity_printed} / {item.quantity_needed} printed
</p>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
{item.name}
<span className="text-bambu-gray font-normal ml-2">
x{item.quantity_needed}
</span>
</p>
{item.unit_price !== null && (
<span className="text-xs text-bambu-green whitespace-nowrap">
{currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
</span>
)}
</div>
<button
onClick={() => handleDeleteBomItem(item.id, item.name)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Sourcing URL */}
{item.sourcing_url && (
<a
href={item.sourcing_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{(() => {
try {
return new URL(item.sourcing_url).hostname.replace('www.', '');
} catch {
return item.sourcing_url;
}
})()}
</span>
</a>
)}
{/* Remarks */}
{item.remarks && (
<p className="mt-1 text-xs text-bambu-gray/80 italic">
{item.remarks}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleIncrementPrinted(item)}
disabled={updateBomMutation.isPending}
className="px-2 py-1 text-xs bg-bambu-dark-tertiary rounded hover:bg-bambu-green/20 text-bambu-gray hover:text-white transition-colors"
>
+1
</button>
<button
onClick={() => handleDeleteBomItem(item.id)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
{/* BOM Total */}
{bomItems.some(item => item.unit_price !== null) && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
<span className="text-bambu-gray">Total cost:</span>
<span className="text-white font-medium">
{currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
</span>
</div>
)}
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No parts in the bill of materials. Add parts to track what needs to be printed.
No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
</p>
)}
</CardContent>
@@ -1011,6 +1152,18 @@ export function ProjectDetailPage() {
isLoading={updateMutation.isPending}
/>
)}
{/* Confirm Modal */}
{confirmModal.isOpen && (
<ConfirmModal
title={confirmModal.title}
message={confirmModal.message}
confirmText="Delete"
variant="danger"
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
/>
)}
</div>
);
}
+110 -40
View File
@@ -240,13 +240,16 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
return (
<div
className="group relative bg-bambu-card rounded-xl border border-bambu-dark-tertiary hover:border-bambu-gray/40 transition-all cursor-pointer overflow-hidden"
className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
onClick={onClick}
>
{/* Color accent bar */}
{/* Color accent bar with glow */}
<div
className="absolute top-0 left-0 w-1 h-full"
style={{ backgroundColor: project.color || '#6b7280' }}
className="absolute top-0 left-0 w-1.5 h-full"
style={{
backgroundColor: project.color || '#6b7280',
boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
}}
/>
<div className="p-5 pl-6">
@@ -257,8 +260,21 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
<statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-white truncate">{project.name}</h3>
{project.target_count ? (
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
progressPercent >= 100
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray'
}`}>
{project.archive_count}/{project.target_count} parts
</span>
) : project.archive_count > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
{project.archive_count} print{project.archive_count !== 1 ? 's' : ''}
</span>
) : null}
{isCompleted && (
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
Done
@@ -275,6 +291,38 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
{project.description}
</p>
)}
{/* Filament materials/colors */}
{project.archives && project.archives.length > 0 && (() => {
const materials = [...new Set(project.archives.map(a => a.filament_type).filter(Boolean))];
const colors = [...new Set(project.archives.map(a => a.filament_color).filter(Boolean))] as string[];
if (materials.length === 0 && colors.length === 0) return null;
return (
<div className="flex items-center gap-2 mt-1.5">
{/* Material types as text badges */}
{materials.slice(0, 3).map((mat) => (
<span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
{mat}
</span>
))}
{/* Colors as swatches */}
{colors.length > 0 && (
<div className="flex items-center gap-0.5">
{colors.slice(0, 5).map((col) => (
<div
key={col}
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
title={col}
/>
))}
{colors.length > 5 && (
<span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
)}
</div>
)}
</div>
);
})()}
</div>
</div>
@@ -310,38 +358,60 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
</div>
</div>
{/* Progress section */}
{project.target_count ? (
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-bambu-gray">Progress</span>
<span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
{project.archive_count} / {project.target_count}
</span>
{/* Progress section - show for all projects */}
<div className="mb-4">
{project.target_count ? (
<>
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-bambu-gray">Progress</span>
<span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
{project.archive_count} / {project.target_count}
</span>
</div>
<div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
<div
className="h-full transition-all duration-500 ease-out rounded-full relative"
style={{
width: `${Math.min(progressPercent, 100)}%`,
background: progressPercent >= 100
? 'linear-gradient(90deg, #22c55e, #4ade80)'
: `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
}}
/>
</div>
<div className="text-right text-xs text-bambu-gray/60 mt-1">
{progressPercent.toFixed(0)}% complete
</div>
</>
) : project.archive_count > 0 ? (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-bambu-gray">
<Archive className="w-3.5 h-3.5" />
<span>{project.archive_count} print{project.archive_count !== 1 ? 's' : ''} completed</span>
</div>
{project.queue_count > 0 && (
<div className="flex items-center gap-1.5 text-blue-400">
<Clock className="w-3.5 h-3.5" />
<span>{project.queue_count} in queue</span>
</div>
)}
</div>
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500 ease-out rounded-full"
style={{
width: `${Math.min(progressPercent, 100)}%`,
backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
) : (
<div className="text-xs text-bambu-gray/60 italic">
No prints yet
</div>
<div className="text-right text-xs text-bambu-gray/60 mt-1">
{progressPercent.toFixed(0)}% complete
</div>
</div>
) : null}
)}
</div>
{/* Archive thumbnails grid */}
{/* Archive thumbnails - compact 4-column grid */}
{project.archives && project.archives.length > 0 && (
<div className="mb-4">
<div className="flex gap-1.5">
<div className="grid grid-cols-4 gap-1.5">
{project.archives.slice(0, 4).map((archive) => (
<div
key={archive.id}
className="relative w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary"
className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
title={archive.print_name || 'Unknown'}
>
{archive.thumbnail_path ? (
@@ -352,22 +422,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
<Package className="w-5 h-5" />
<Package className="w-6 h-6" />
</div>
)}
{archive.status === 'failed' && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-white" />
</div>
)}
</div>
))}
{project.archive_count > 4 && (
<div className="w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-xs text-bambu-gray border border-bambu-dark-tertiary">
+{project.archive_count - 4}
</div>
)}
</div>
{project.archive_count > 4 && (
<p className="text-xs text-bambu-gray mt-1.5 text-center">
+{project.archive_count - 4} more
</p>
)}
</div>
)}
@@ -480,17 +550,17 @@ export function ProjectsPage() {
}, {} as Record<string, number>) || {};
return (
<div className="space-y-6">
<div className="p-4 md:p-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-bambu-green/10 rounded-lg">
<div className="p-2.5 bg-bambu-green/10 rounded-xl">
<FolderKanban className="w-6 h-6 text-bambu-green" />
</div>
Projects
</h1>
<p className="text-sm text-bambu-gray mt-1 ml-14">
<p className="text-sm text-bambu-gray mt-2 ml-14">
Organize and track your 3D printing projects
</p>
</div>
@@ -560,7 +630,7 @@ export function ProjectsPage() {
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{projects?.map((project) => (
<ProjectCard
key={project.id}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DpxuzRSm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUXYdb1N.css">
<script type="module" crossorigin src="/assets/index-BonMKEhM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGJjegsB.css">
</head>
<body>
<div id="root"></div>