diff --git a/backend/app/main.py b/backend/app/main.py
index 4d94dce..83c1789 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1554,6 +1554,240 @@ def get_inspections_report(
]
+@app.get("/api/inspections/{inspection_id}/pdf")
+def export_inspection_to_pdf(
+ inspection_id: int,
+ current_user: models.User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Exportar inspección a PDF con imágenes"""
+ from fastapi.responses import StreamingResponse
+ from reportlab.lib.pagesizes import letter, A4
+ from reportlab.lib import colors
+ from reportlab.lib.units import inch
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
+ from io import BytesIO
+ import base64
+
+ # Obtener inspección
+ inspection = db.query(models.Inspection).filter(
+ models.Inspection.id == inspection_id
+ ).first()
+
+ if not inspection:
+ raise HTTPException(status_code=404, detail="Inspección no encontrada")
+
+ # Verificar permisos (admin o mecánico dueño)
+ if current_user.role != "admin" and inspection.mechanic_id != current_user.id:
+ raise HTTPException(status_code=403, detail="No tienes permisos para ver esta inspección")
+
+ # Obtener datos relacionados
+ checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first()
+ mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first()
+ answers = db.query(models.Answer).options(
+ joinedload(models.Answer.media_files)
+ ).join(models.Question).filter(
+ models.Answer.inspection_id == inspection_id
+ ).order_by(models.Question.section, models.Question.order).all()
+
+ # Crear PDF en memoria
+ buffer = BytesIO()
+ doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
+
+ # Contenedor para elementos del PDF
+ elements = []
+ styles = getSampleStyleSheet()
+
+ # Estilos personalizados
+ title_style = ParagraphStyle(
+ 'CustomTitle',
+ parent=styles['Heading1'],
+ fontSize=24,
+ textColor=colors.HexColor('#4338ca'),
+ spaceAfter=30,
+ alignment=TA_CENTER
+ )
+
+ heading_style = ParagraphStyle(
+ 'CustomHeading',
+ parent=styles['Heading2'],
+ fontSize=16,
+ textColor=colors.HexColor('#4338ca'),
+ spaceAfter=12,
+ spaceBefore=12
+ )
+
+ # Título
+ elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style))
+ elements.append(Spacer(1, 20))
+
+ # Información general
+ info_data = [
+ ['Checklist:', checklist.name if checklist else 'N/A'],
+ ['Mecánico:', mechanic.full_name if mechanic else 'N/A'],
+ ['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'],
+ ['Estado:', inspection.status.upper()],
+ ['', ''],
+ ['Vehículo', ''],
+ ['Patente:', inspection.vehicle_plate or 'N/A'],
+ ['Marca:', inspection.vehicle_brand or 'N/A'],
+ ['Modelo:', inspection.vehicle_model or 'N/A'],
+ ['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'],
+ ['Cliente:', inspection.client_name or 'N/A'],
+ ['OR Número:', inspection.or_number or 'N/A'],
+ ]
+
+ if inspection.status == 'completed' and inspection.score is not None:
+ info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"])
+ info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)])
+
+ info_table = Table(info_data, colWidths=[2*inch, 4*inch])
+ info_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')),
+ ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')),
+ ('ALIGN', (0, 0), (0, -1), 'RIGHT'),
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
+ ('TOPPADDING', (0, 0), (-1, -1), 8),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ]))
+
+ elements.append(info_table)
+ elements.append(Spacer(1, 30))
+
+ # Respuestas por sección
+ if answers:
+ elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style))
+ elements.append(Spacer(1, 10))
+
+ current_section = None
+
+ for answer in answers:
+ question = answer.question
+
+ # Nueva sección
+ if question.section != current_section:
+ if current_section is not None:
+ elements.append(Spacer(1, 20))
+
+ current_section = question.section
+ section_style = ParagraphStyle(
+ 'Section',
+ parent=styles['Heading3'],
+ fontSize=14,
+ textColor=colors.HexColor('#6366f1'),
+ spaceAfter=10
+ )
+ elements.append(Paragraph(f"● {current_section}", section_style))
+
+ # Datos de la pregunta
+ answer_color = colors.white
+ if answer.is_flagged:
+ answer_color = colors.HexColor('#fee2e2')
+ elif answer.answer_value in ['pass', 'good']:
+ answer_color = colors.HexColor('#dcfce7')
+ elif answer.answer_value in ['fail', 'bad']:
+ answer_color = colors.HexColor('#fee2e2')
+
+ answer_text = answer.answer_value or answer.answer_text or 'N/A'
+ if answer.points_earned is not None:
+ answer_text += f" ({answer.points_earned} pts)"
+
+ question_data = [
+ [Paragraph(f"{question.text}", styles['Normal']), answer_text.upper()]
+ ]
+
+ if answer.comment:
+ question_data.append([Paragraph(f"Comentarios: {answer.comment}", styles['Normal']), ''])
+
+ question_table = Table(question_data, colWidths=[4*inch, 2*inch])
+ question_table.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), answer_color),
+ ('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
+ ('ALIGN', (1, 0), (1, 0), 'CENTER'),
+ ('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ]))
+
+ elements.append(question_table)
+ elements.append(Spacer(1, 5))
+
+ # Fotos adjuntas
+ if answer.media_files and len(answer.media_files) > 0:
+ elements.append(Spacer(1, 5))
+ photos_per_row = 2
+ photo_width = 2.5 * inch
+ photo_height = 2 * inch
+
+ for i in range(0, len(answer.media_files), photos_per_row):
+ photo_row = []
+ for media_file in answer.media_files[i:i+photos_per_row]:
+ try:
+ photo_path = media_file.file_path
+ # Si la foto es base64
+ if photo_path.startswith('data:image'):
+ img_data = base64.b64decode(photo_path.split(',')[1])
+ img_buffer = BytesIO(img_data)
+ img = RLImage(img_buffer, width=photo_width, height=photo_height)
+ else:
+ # Si es una ruta de archivo
+ full_path = os.path.join(os.getcwd(), photo_path)
+ if os.path.exists(full_path):
+ img = RLImage(full_path, width=photo_width, height=photo_height)
+ else:
+ continue
+ photo_row.append(img)
+ except Exception as e:
+ print(f"Error loading image: {e}")
+ continue
+
+ if photo_row:
+ photo_table = Table([photo_row])
+ photo_table.setStyle(TableStyle([
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ]))
+ elements.append(photo_table)
+ elements.append(Spacer(1, 10))
+
+ else:
+ elements.append(Paragraph("No hay respuestas registradas", styles['Normal']))
+
+ # Pie de página
+ elements.append(Spacer(1, 30))
+ footer_style = ParagraphStyle(
+ 'Footer',
+ parent=styles['Normal'],
+ fontSize=8,
+ textColor=colors.grey,
+ alignment=TA_CENTER
+ )
+ elements.append(Paragraph(f"Generado por Syntria - {datetime.now().strftime('%d/%m/%Y %H:%M')}", footer_style))
+
+ # Construir PDF
+ doc.build(elements)
+
+ # Preparar respuesta
+ buffer.seek(0)
+ filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf"
+
+ return StreamingResponse(
+ buffer,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}"
+ }
+ )
+
+
# ============= HEALTH CHECK =============
@app.get("/")
def root():
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a27d3f1..102b560 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -339,10 +339,7 @@ function DashboardPage({ user, setUser }) {
) : activeTab === 'users' ? (