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' ? ( ) : activeTab === 'reports' ? ( -
-
📊
-
Módulo de Reportes en desarrollo...
-
+ ) : null} @@ -1732,6 +1729,38 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) { {/* Footer */}
+ {user?.role === 'admin' && ( <>