develop #1

Merged
gitea merged 44 commits from develop into main 2025-11-26 01:15:20 +00:00
2 changed files with 534 additions and 4 deletions
Showing only changes of commit cfe49ee0c8 - Show all commits

View File

@@ -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"<b>{question.text}</b>", styles['Normal']), answer_text.upper()]
]
if answer.comment:
question_data.append([Paragraph(f"<i>Comentarios: {answer.comment}</i>", 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():

View File

@@ -339,10 +339,7 @@ function DashboardPage({ user, setUser }) {
) : activeTab === 'users' ? (
<UsersTab user={user} />
) : activeTab === 'reports' ? (
<div className="text-center py-12">
<div className="text-xl text-gray-400">📊</div>
<div className="text-gray-500 mt-2">Módulo de Reportes en desarrollo...</div>
</div>
<ReportsTab user={user} />
) : null}
</div>
</div>
@@ -1732,6 +1729,38 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
{/* Footer */}
<div className="border-t p-4 bg-gray-50">
<div className="flex gap-3">
<button
onClick={async () => {
try {
const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || ''
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/pdf`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.ok) {
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `inspeccion_${inspection.id}_${inspection.vehicle_plate || 'sin-patente'}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} else {
alert('Error al generar PDF')
}
} catch (error) {
console.error('Error:', error)
alert('Error al generar PDF')
}
}}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition flex items-center gap-2"
>
<span>📄</span>
Exportar PDF
</button>
{user?.role === 'admin' && (
<>
<button
@@ -3142,5 +3171,272 @@ function UsersTab({ user }) {
)
}
// Componente de Reportes
function ReportsTab({ user }) {
const [dashboardData, setDashboardData] = useState(null)
const [inspections, setInspections] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({
startDate: '',
endDate: '',
mechanicId: ''
})
const loadDashboard = async () => {
try {
const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || ''
let url = `${API_URL}/api/reports/dashboard?`
if (filters.startDate) url += `start_date=${filters.startDate}&`
if (filters.endDate) url += `end_date=${filters.endDate}&`
if (filters.mechanicId) url += `mechanic_id=${filters.mechanicId}&`
console.log('Loading dashboard from:', url)
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
})
console.log('Dashboard response status:', response.status)
if (response.ok) {
const data = await response.json()
console.log('Dashboard data:', data)
setDashboardData(data)
} else {
const errorText = await response.text()
console.error('Dashboard error response:', response.status, errorText)
}
} catch (error) {
console.error('Error loading dashboard:', error)
}
}
const loadInspections = async () => {
try {
const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || ''
let url = `${API_URL}/api/reports/inspections?`
if (filters.startDate) url += `start_date=${filters.startDate}&`
if (filters.endDate) url += `end_date=${filters.endDate}&`
if (filters.mechanicId) url += `mechanic_id=${filters.mechanicId}&`
console.log('Loading inspections from:', url)
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
})
console.log('Inspections response status:', response.status)
if (response.ok) {
const data = await response.json()
console.log('Inspections data:', data)
setInspections(data)
} else {
const errorText = await response.text()
console.error('Inspections error response:', response.status, errorText)
}
} catch (error) {
console.error('Error loading inspections:', error)
}
}
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([loadDashboard(), loadInspections()])
setLoading(false)
}
loadData()
}, [filters])
if (loading) {
return <div className="text-center py-12">Cargando reportes...</div>
}
if (!dashboardData || !dashboardData.stats) {
return <div className="text-center py-12">No hay datos disponibles</div>
}
return (
<div className="space-y-6">
{/* Filtros */}
<div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Inicio
</label>
<input
type="date"
value={filters.startDate}
onChange={(e) => setFilters({...filters, startDate: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Fin
</label>
<input
type="date"
value={filters.endDate}
onChange={(e) => setFilters({...filters, endDate: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
{user.role === 'admin' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mecánico
</label>
<input
type="number"
placeholder="ID del mecánico"
value={filters.mechanicId}
onChange={(e) => setFilters({...filters, mechanicId: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
)}
</div>
</div>
{/* Métricas principales */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Inspecciones</div>
<div className="text-3xl font-bold text-indigo-600">
{dashboardData.stats?.total_inspections || 0}
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Completadas</div>
<div className="text-3xl font-bold text-green-600">
{dashboardData.stats?.completed_inspections || 0}
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Tasa de Completado</div>
<div className="text-3xl font-bold text-blue-600">
{(dashboardData.stats?.completion_rate || 0).toFixed(1)}%
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Promedio Score</div>
<div className="text-3xl font-bold text-purple-600">
{(dashboardData.stats?.avg_score || 0).toFixed(1)}
</div>
</div>
</div>
{/* Ranking de Mecánicos */}
{user.role === 'admin' && dashboardData.mechanic_ranking?.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">🏆 Ranking de Mecánicos</h3>
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-4">Posición</th>
<th className="text-left py-2 px-4">Mecánico</th>
<th className="text-right py-2 px-4">Inspecciones</th>
<th className="text-right py-2 px-4">Promedio</th>
<th className="text-right py-2 px-4">% Completado</th>
</tr>
</thead>
<tbody>
{dashboardData.mechanic_ranking.map((mechanic, index) => (
<tr key={mechanic.mechanic_id} className="border-b hover:bg-gray-50">
<td className="py-2 px-4">
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
</td>
<td className="py-2 px-4 font-medium">{mechanic.mechanic_name}</td>
<td className="py-2 px-4 text-right">{mechanic.total_inspections}</td>
<td className="py-2 px-4 text-right">{mechanic.avg_score.toFixed(1)}</td>
<td className="py-2 px-4 text-right">{mechanic.completion_rate.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Estadísticas por Checklist */}
{dashboardData.checklist_stats?.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">📋 Estadísticas por Checklist</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{dashboardData.checklist_stats.map((checklist) => (
<div key={checklist.checklist_id} className="border rounded-lg p-4">
<div className="font-medium text-gray-900 mb-2">{checklist.checklist_name}</div>
<div className="text-sm text-gray-600 space-y-1">
<div>Inspecciones: {checklist.total_inspections}</div>
<div>Promedio: {checklist.avg_score.toFixed(1)}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Lista de Inspecciones */}
{inspections.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">📝 Inspecciones Recientes</h3>
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-4">Fecha</th>
<th className="text-left py-2 px-4">Checklist</th>
<th className="text-left py-2 px-4">Mecánico</th>
<th className="text-left py-2 px-4">Placa</th>
<th className="text-right py-2 px-4">Score</th>
<th className="text-center py-2 px-4">Estado</th>
<th className="text-center py-2 px-4">Alertas</th>
</tr>
</thead>
<tbody>
{inspections.slice(0, 20).map((inspection) => (
<tr key={inspection.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-4">
{new Date(inspection.started_at).toLocaleDateString()}
</td>
<td className="py-2 px-4">{inspection.checklist_name}</td>
<td className="py-2 px-4">{inspection.mechanic_name}</td>
<td className="py-2 px-4 font-mono">{inspection.vehicle_plate}</td>
<td className="py-2 px-4 text-right font-medium">{inspection.score}</td>
<td className="py-2 px-4 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
inspection.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{inspection.status === 'completed' ? 'Completada' : 'Pendiente'}
</span>
</td>
<td className="py-2 px-4 text-center">
{inspection.flagged_items > 0 && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 rounded-full">
🚩 {inspection.flagged_items}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
export default App