Frontend (v1.0.88) QuestionTypeEditor.jsx ✅ Nuevo tipo: ai_assistant con icono 💬 ✅ Configuración completa: assistant_prompt: Define rol y comportamiento del asistente context_questions: IDs de preguntas anteriores cuyas fotos usar (o todas) assistant_instructions: Reglas específicas de diagnóstico max_messages: Límite de mensajes en el chat response_length: Corta/Media/Larga QuestionAnswerInput.jsx ✅ Mensaje informativo para tipo ai_assistant ✅ Indica que el chat se abre con botón separado App.jsx - Modal de Chat IA ✅ Modal full-screen responsive con: Header con gradiente purple/blue Área de mensajes con scroll Input de texto + botón enviar Soporte Enter para enviar Indicador de "pensando..." Timestamps en mensajes Confianza de la IA Límite de mensajes ✅ Botón "💬 Consultar Asistente IA" al lado de "Cargar Documentos" ✅ Contador de mensajes en el botón si ya hay historial ✅ Historial guardado en answers[questionId].chatHistory ✅ Auto-marca como completada cuando se abre el chat Backend (v1.0.85) Endpoint /api/ai/chat-assistant ✅ Recibe: Mensaje del usuario Historial del chat Fotos de preguntas anteriores con sus análisis Configuración del asistente Info del vehículo ✅ Contexto inteligente: Información del vehículo (marca, modelo, km, placa) Análisis previos de fotos (hasta 10 fotos más relevantes) Instrucciones personalizadas por pregunta Historial conversacional (últimos 10 mensajes) ✅ Soporte multi-proveedor: OpenAI (GPT-4, GPT-3.5) Google Gemini ✅ Respuestas adaptables: Longitud configurable (200/400/800 tokens) Temperature 0.7 para balance creatividad/precisión Cómo Funciona Configuración (Admin): - Crear pregunta tipo "Asistente IA (Chat)" - Definir prompt: "Eres experto en sistemas de frenos..." - Seleccionar preguntas anteriores: "5,8,12" (fotos de frenos) - Instrucciones: "Si pastillas < 3mm → cambio inmediato" - Longitud: Media - Max mensajes: 20 2 Durante Inspección (Mecánico): - Llega a pregunta tipo AI Assistant - Ve botón "💬 Consultar Asistente IA" - Clic → Abre chat full-screen - La IA YA vio las fotos de preguntas 5, 8, 12 - Mecánico pregunta: "¿Las pastillas están bien?" - IA responde basado en fotos previas + contexto vehículo - Conversación natural con historial - Al cerrar → se guarda automáticamente 3 Ejemplo de Conversación: Mecánico: "Vi desgaste en las pastillas, ¿qué recomiendas?" IA: "Basándome en las fotos que analicé: Pastilla delantera izquierda: ~2.5mm de espesor Pastilla delantera derecha: ~2.8mm ⚠️ CRÍTICO: Ambas pastillas están por debajo del mínimo de 3mm. Con 45,000 km, es normal este desgaste. RECOMENDACIÓN: - Cambio INMEDIATO de pastillas delanteras - Verificar estado de discos (posible rectificado) - Revisar líquido de frenos (última foto muestra nivel bajo) ¿Necesitas el código de pieza para este Toyota Corolla 2019?" Casos de Uso Diagnóstico de Frenos assistant_prompt: "Eres especialista en sistemas de frenos. Analiza desgaste, fugas, vibraciones." context_questions: "10,11,12,13" // Fotos de pastillas, discos, líquido assistant_prompt: "Experto en motores. Detecta fugas, ruidos anormales, consumo excesivo." context_questions: "5,6,7,8,9" // Motor, aceite, correa, filtros assistant_prompt: "Especialista en sistemas eléctricos y electrónicos." context_questions: "20,21,22" // Batería, luces, tablero instructions: "Siempre pedir código OBD2 si hay check engine" Ventajas ✅ Contextual: La IA ve fotos previas, no pregunta "¿puedes mostrarme?" ✅ Especializado: Un asistente POR tema (frenos, motor, eléctrico) ✅ Conversacional: El mecánico puede hacer follow-up questions ✅ Guiado: Instrucciones específicas por tipo de inspección ✅ Historial: No repite info, mantiene contexto de la conversación ✅ Móvil-friendly: Modal responsive, fácil de usar en celular
6390 lines
264 KiB
JavaScript
6390 lines
264 KiB
JavaScript
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import SignatureCanvas from 'react-signature-canvas'
|
||
import Sidebar from './Sidebar'
|
||
import QuestionTypeEditor from './QuestionTypeEditor'
|
||
import QuestionAnswerInput from './QuestionAnswerInput'
|
||
|
||
function App() {
|
||
const [user, setUser] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||
const [waitingWorker, setWaitingWorker] = useState(null)
|
||
|
||
// Detectar actualizaciones del Service Worker
|
||
useEffect(() => {
|
||
if ('serviceWorker' in navigator) {
|
||
// Registrar service worker
|
||
navigator.serviceWorker.register('/service-worker.js')
|
||
.then((registration) => {
|
||
console.log('✅ Service Worker registrado:', registration);
|
||
|
||
// Verificar si hay actualización esperando
|
||
if (registration.waiting) {
|
||
setWaitingWorker(registration.waiting);
|
||
setUpdateAvailable(true);
|
||
}
|
||
|
||
// Detectar cuando hay nueva versión instalándose
|
||
registration.addEventListener('updatefound', () => {
|
||
const newWorker = registration.installing;
|
||
console.log('🔄 Nueva versión detectada, instalando...');
|
||
|
||
newWorker.addEventListener('statechange', () => {
|
||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||
// Hay nueva versión disponible
|
||
console.log('✨ Nueva versión lista!');
|
||
setWaitingWorker(newWorker);
|
||
setUpdateAvailable(true);
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.error('❌ Error al registrar Service Worker:', error);
|
||
});
|
||
|
||
// Escuchar cambios de controlador (cuando se activa nueva versión)
|
||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||
console.log('🔄 Controlador cambiado, recargando página...');
|
||
window.location.reload();
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// Función para actualizar la app
|
||
const handleUpdate = () => {
|
||
if (waitingWorker) {
|
||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
// Verificar si hay token guardado
|
||
const token = localStorage.getItem('token')
|
||
const userData = localStorage.getItem('user')
|
||
|
||
if (token && userData) {
|
||
setUser(JSON.parse(userData))
|
||
}
|
||
setLoading(false)
|
||
}, [])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||
<div className="text-xl">Cargando...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Router>
|
||
<div className="min-h-screen bg-gray-50">
|
||
{/* Modal de actualización disponible */}
|
||
{updateAvailable && (
|
||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999] p-4">
|
||
<div className="bg-white rounded-2xl max-w-md w-full p-6 sm:p-8 shadow-2xl animate-bounce">
|
||
<div className="text-center">
|
||
<div className="mb-4 flex justify-center">
|
||
<div className="w-20 h-20 bg-gradient-to-r from-green-500 to-emerald-500 rounded-full flex items-center justify-center">
|
||
<span className="text-4xl">🔄</span>
|
||
</div>
|
||
</div>
|
||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3">
|
||
¡Nueva Actualización!
|
||
</h2>
|
||
<p className="text-gray-600 mb-6 text-sm sm:text-base">
|
||
Hay una nueva versión disponible con mejoras y correcciones.
|
||
<br />
|
||
<strong className="text-indigo-600">Por favor actualiza para continuar.</strong>
|
||
</p>
|
||
<button
|
||
onClick={handleUpdate}
|
||
className="w-full py-4 px-6 bg-gradient-to-r from-indigo-600 to-purple-600 text-white text-lg sm:text-xl font-bold rounded-xl hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
🚀 ACTUALIZAR AHORA
|
||
</button>
|
||
<p className="text-xs text-gray-400 mt-4">
|
||
La página se recargará automáticamente
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!user ? (
|
||
<LoginPage setUser={setUser} />
|
||
) : (
|
||
<DashboardPage user={user} setUser={setUser} />
|
||
)}
|
||
</div>
|
||
</Router>
|
||
)
|
||
}
|
||
|
||
// Componente de Login
|
||
function LoginPage({ setUser }) {
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const res = await fetch(`${API_URL}/api/config/logo`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
const handleLogin = async (e) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
setLoading(true)
|
||
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/auth/login`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ username, password }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || 'Error al iniciar sesión')
|
||
}
|
||
|
||
console.log('Login successful, token:', data.access_token.substring(0, 20) + '...')
|
||
|
||
// Guardar token y usuario
|
||
localStorage.setItem('token', data.access_token)
|
||
localStorage.setItem('user', JSON.stringify(data.user))
|
||
setUser(data.user)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-600 via-purple-600 to-blue-600 px-4">
|
||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||
{/* Header con Logo */}
|
||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-center">
|
||
<div className="flex justify-center mb-4">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-[70px] w-[203px] object-contain bg-white rounded-2xl shadow-lg" />
|
||
) : (
|
||
<div className="h-[70px] w-[203px] bg-white rounded-2xl flex items-center justify-center shadow-lg text-gray-400">Sin logo</div>
|
||
)}
|
||
</div>
|
||
<h1 className="text-4xl font-bold text-white mb-2">AYUTEC</h1>
|
||
<p className="text-indigo-100 text-sm">Sistema Inteligente de Inspecciones</p>
|
||
</div>
|
||
|
||
{/* Formulario */}
|
||
<div className="px-8 py-8">
|
||
<form onSubmit={handleLogin} className="space-y-6">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||
placeholder="Ingresa tu usuario"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Contraseña
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||
placeholder="••••••••"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm flex items-center gap-2">
|
||
<span>⚠️</span>
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-3 rounded-xl font-semibold hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none shadow-lg"
|
||
>
|
||
{loading ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||
Iniciando sesión...
|
||
</span>
|
||
) : (
|
||
'Iniciar Sesión'
|
||
)}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente Dashboard
|
||
function DashboardPage({ user, setUser }) {
|
||
const [checklists, setChecklists] = useState([])
|
||
const [inspections, setInspections] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [activeTab, setActiveTab] = useState('checklists')
|
||
const [activeInspection, setActiveInspection] = useState(null)
|
||
// Sidebar cerrado por defecto en móvil
|
||
const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth >= 1024)
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const res = await fetch(`${API_URL}/api/config/logo`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log('Token:', token ? 'exists' : 'missing')
|
||
|
||
if (!token) {
|
||
console.warn('No token found, redirecting to login')
|
||
setUser(null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
// Cargar checklists
|
||
const checklistsRes = await fetch(`${API_URL}/api/checklists?active_only=true`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
},
|
||
})
|
||
|
||
console.log('Checklists response:', checklistsRes.status)
|
||
|
||
if (checklistsRes.status === 401) {
|
||
console.warn('Token expired or invalid, logging out')
|
||
localStorage.removeItem('token')
|
||
localStorage.removeItem('user')
|
||
setUser(null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
if (checklistsRes.ok) {
|
||
const checklistsData = await checklistsRes.json()
|
||
console.log('Checklists data:', checklistsData)
|
||
// Ordenar por ID descendente para mantener orden consistente
|
||
const sortedChecklists = Array.isArray(checklistsData)
|
||
? checklistsData.sort((a, b) => b.id - a.id)
|
||
: []
|
||
setChecklists(sortedChecklists)
|
||
} else {
|
||
console.error('Error loading checklists:', checklistsRes.status)
|
||
setChecklists([])
|
||
}
|
||
|
||
// Cargar inspecciones
|
||
const inspectionsRes = await fetch(`${API_URL}/api/inspections?limit=10`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
},
|
||
})
|
||
|
||
console.log('Inspections response:', inspectionsRes.status)
|
||
|
||
if (inspectionsRes.ok) {
|
||
const inspectionsData = await inspectionsRes.json()
|
||
console.log('Inspections data:', inspectionsData)
|
||
// Ordenar por ID descendente para mantener orden consistente
|
||
const sortedInspections = Array.isArray(inspectionsData)
|
||
? inspectionsData.sort((a, b) => b.id - a.id)
|
||
: []
|
||
setInspections(sortedInspections)
|
||
} else {
|
||
console.error('Error loading inspections:', inspectionsRes.status)
|
||
setInspections([])
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error loading data:', error)
|
||
setChecklists([])
|
||
setInspections([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('token')
|
||
localStorage.removeItem('user')
|
||
setUser(null)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-blue-50 flex">
|
||
{/* Sidebar */}
|
||
<Sidebar
|
||
user={user}
|
||
activeTab={activeTab}
|
||
setActiveTab={setActiveTab}
|
||
sidebarOpen={sidebarOpen}
|
||
setSidebarOpen={setSidebarOpen}
|
||
onLogout={handleLogout}
|
||
/>
|
||
|
||
{/* Main Content */}
|
||
<div className={`flex-1 flex flex-col transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'lg:ml-16'}`}>
|
||
{/* Header */}
|
||
<header className="bg-gradient-to-r from-indigo-600 to-purple-600 shadow-lg">
|
||
<div className="px-3 sm:px-4 lg:px-8 py-3 sm:py-4">
|
||
<div className="flex items-center justify-between gap-2">
|
||
{/* Botón hamburguesa (solo móvil) */}
|
||
<button
|
||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||
className="lg:hidden p-2 rounded-lg bg-white/10 hover:bg-white/20 transition"
|
||
aria-label="Menú"
|
||
>
|
||
<span className="text-white text-2xl">☰</span>
|
||
</button>
|
||
|
||
{/* Logo y Nombre del Sistema */}
|
||
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-[50px] w-[145px] sm:h-[70px] sm:w-[203px] object-contain bg-white rounded-lg sm:rounded-xl shadow-lg flex-shrink-0" />
|
||
) : (
|
||
<div className="h-[50px] w-[145px] sm:h-[70px] sm:w-[203px] bg-white rounded-lg sm:rounded-xl flex items-center justify-center shadow-lg text-gray-400 text-xs flex-shrink-0">Sin logo</div>
|
||
)}
|
||
<div className="hidden sm:block">
|
||
<h1 className="text-xl sm:text-2xl font-bold text-white">AYUTEC</h1>
|
||
<p className="text-xs text-indigo-200">Sistema Inteligente de Inspecciones</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sección Activa */}
|
||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 bg-white/10 backdrop-blur-sm px-3 sm:px-4 py-2 rounded-lg border border-white/20">
|
||
<span className="text-xl sm:text-2xl">
|
||
{activeTab === 'checklists' && '📋'}
|
||
{activeTab === 'inspections' && '🔍'}
|
||
{activeTab === 'users' && '👥'}
|
||
{activeTab === 'reports' && '📊'}
|
||
{activeTab === 'settings' && '⚙️'}
|
||
</span>
|
||
<span className="text-white font-semibold text-sm sm:text-base">
|
||
{activeTab === 'checklists' && 'Checklists'}
|
||
{activeTab === 'inspections' && 'Inspecciones'}
|
||
{activeTab === 'users' && 'Usuarios'}
|
||
{activeTab === 'reports' && 'Reportes'}
|
||
{activeTab === 'settings' && 'Configuración'}
|
||
</span>
|
||
</div>
|
||
{/* Indicador móvil (solo icono) */}
|
||
<div className="sm:hidden flex items-center justify-center w-10 h-10 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20">
|
||
<span className="text-xl">
|
||
{activeTab === 'checklists' && '📋'}
|
||
{activeTab === 'inspections' && '🔍'}
|
||
{activeTab === 'users' && '👥'}
|
||
{activeTab === 'reports' && '📊'}
|
||
{activeTab === 'settings' && '⚙️'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 p-3 sm:p-4 lg:p-6">
|
||
<div className="bg-white rounded-xl sm:rounded-2xl shadow-xl overflow-hidden h-full border border-indigo-100">
|
||
<div className="p-3 sm:p-4 lg:p-6">
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando datos...</div>
|
||
</div>
|
||
) : activeTab === 'checklists' ? (
|
||
<ChecklistsTab
|
||
checklists={checklists}
|
||
user={user}
|
||
onChecklistCreated={loadData}
|
||
onStartInspection={setActiveInspection}
|
||
/>
|
||
) : activeTab === 'inspections' ? (
|
||
<InspectionsTab inspections={inspections} user={user} onUpdate={loadData} onContinue={setActiveInspection} />
|
||
) : activeTab === 'settings' ? (
|
||
<SettingsTab user={user} />
|
||
) : activeTab === 'api-tokens' ? (
|
||
<APITokensTab user={user} />
|
||
) : activeTab === 'users' ? (
|
||
<UsersTab user={user} />
|
||
) : activeTab === 'reports' ? (
|
||
<ReportsTab user={user} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal de Inspección Activa */}
|
||
{activeInspection && (
|
||
<InspectionModal
|
||
checklist={activeInspection.checklist_id ? activeInspection.checklist : activeInspection}
|
||
existingInspection={activeInspection.checklist_id ? activeInspection : null}
|
||
user={user}
|
||
onClose={() => setActiveInspection(null)}
|
||
onComplete={() => {
|
||
setActiveInspection(null)
|
||
loadData()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingsTab({ user }) {
|
||
// Estado para el logo
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
const [logoUploading, setLogoUploading] = useState(false);
|
||
const [aiConfig, setAiConfig] = useState(null);
|
||
const [availableModels, setAvailableModels] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [formData, setFormData] = useState({
|
||
provider: 'openai',
|
||
api_key: '',
|
||
model_name: 'gpt-4o'
|
||
});
|
||
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
}, []);
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
|
||
// Cargar modelos disponibles
|
||
const modelsRes = await fetch(`${API_URL}/api/ai/models`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (modelsRes.ok) {
|
||
const models = await modelsRes.json();
|
||
setAvailableModels(models);
|
||
}
|
||
|
||
// Cargar configuración actual
|
||
const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (configRes.ok) {
|
||
const config = await configRes.json();
|
||
setAiConfig(config);
|
||
setFormData({
|
||
provider: config.provider || 'openai',
|
||
api_key: config.api_key || '',
|
||
model_name: config.model_name || 'gpt-4o'
|
||
});
|
||
} else if (configRes.status === 404) {
|
||
// No hay configuración guardada, usar valores por defecto
|
||
console.log('No hay configuración de IA guardada');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading settings:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleLogoUpload = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
setLogoUploading(true);
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const formDataLogo = new FormData();
|
||
formDataLogo.append('file', file);
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formDataLogo
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
alert('Logo actualizado correctamente');
|
||
} else {
|
||
alert('Error al subir el logo');
|
||
}
|
||
} catch {
|
||
alert('Error al subir el logo');
|
||
} finally {
|
||
setLogoUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const response = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
});
|
||
if (response.ok) {
|
||
alert('Configuración guardada correctamente');
|
||
loadSettings();
|
||
} else {
|
||
alert('Error al guardar configuración');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
alert('Error al guardar configuración');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const filteredModels = availableModels.filter(m => m.provider === formData.provider);
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<form onSubmit={handleSave}>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Logo del Sistema</h2>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-[70px] w-[203px] object-contain rounded-xl border shadow" />
|
||
) : (
|
||
<div className="h-[70px] w-[203px] bg-gray-200 rounded-xl flex items-center justify-center text-gray-400">Sin logo</div>
|
||
)}
|
||
<div>
|
||
<input type="file" accept="image/*" onChange={handleLogoUpload} disabled={logoUploading} />
|
||
{logoUploading && <span className="ml-2 text-blue-600">Subiendo...</span>}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-2">El logo se mostrará en el login y en la página principal.</p>
|
||
</div>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Configuración de IA</h2>
|
||
<p className="text-sm text-gray-600 mt-1">Configura el proveedor y modelo de IA para análisis de imágenes</p>
|
||
<div className="flex gap-4 mt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'openai', model_name: 'gpt-4o' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'openai' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">🤖</div>
|
||
<div className="font-semibold">OpenAI</div>
|
||
<div className="text-xs text-gray-600 mt-1">GPT-4, GPT-4 Vision</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'gemini', model_name: 'gemini-2.5-pro' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'gemini' ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">✨</div>
|
||
<div className="font-semibold">Google Gemini</div>
|
||
<div className="text-xs text-gray-600 mt-1">Gemini Pro, Flash</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">API Key</h3>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{formData.provider === 'openai' ? 'OpenAI API Key' : 'Google AI API Key'}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData.api_key}
|
||
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'}
|
||
required
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
{formData.provider === 'openai' ? (
|
||
<>Obtén tu API key en <a href="https://platform.openai.com/api-keys" target="_blank" className="text-blue-600 hover:underline">OpenAI Platform</a></>
|
||
) : (
|
||
<>Obtén tu API key en <a href="https://makersuite.google.com/app/apikey" target="_blank" className="text-blue-600 hover:underline">Google AI Studio</a></>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modelo de IA</h3>
|
||
{loading ? (
|
||
<div className="text-gray-500">Cargando modelos...</div>
|
||
) : filteredModels.length === 0 ? (
|
||
<div className="text-gray-500">No hay modelos disponibles para {formData.provider}</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{filteredModels.map((model) => (
|
||
<label key={model.id} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${formData.model_name === model.id ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
|
||
<input
|
||
type="radio"
|
||
name="model_name"
|
||
value={model.id}
|
||
checked={formData.model_name === model.id}
|
||
onChange={() => setFormData({ ...formData, model_name: model.id })}
|
||
className="form-radio text-indigo-600"
|
||
/>
|
||
<div className="flex-1">
|
||
<div className="font-semibold text-gray-900">{model.name}</div>
|
||
<div className="text-xs text-gray-500 mt-1">{model.description}</div>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg disabled:opacity-50 disabled:transform-none"
|
||
disabled={saving}
|
||
>
|
||
{saving ? 'Guardando...' : 'Guardar Configuración'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function APITokensTab({ user }) {
|
||
const [tokens, setTokens] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [newTokenDescription, setNewTokenDescription] = useState('')
|
||
const [createdToken, setCreatedToken] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadTokens()
|
||
}, [])
|
||
|
||
const loadTokens = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setTokens(data)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading tokens:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateToken = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ description: newTokenDescription || null }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCreatedToken(data.token)
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al crear token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear token')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleRevokeToken = async (tokenId) => {
|
||
if (!confirm('¿Estás seguro de revocar este token? Esta acción no se puede deshacer.')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens/${tokenId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
alert('Token revocado correctamente')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al revocar token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al revocar token')
|
||
}
|
||
}
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text)
|
||
alert('Token copiado al portapapeles')
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<div className="mb-6 flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-gray-900">Mis API Tokens</h2>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
Genera tokens para acceder a la API sin necesidad de login
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Generar Nuevo Token
|
||
</button>
|
||
</div>
|
||
|
||
{/* Modal de Token Creado */}
|
||
{createdToken && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-bold text-gray-900">Token Creado Exitosamente</h3>
|
||
<p className="text-sm text-yellow-600 mt-2">
|
||
⚠️ Guarda este token ahora. No podrás verlo de nuevo.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-4 mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 text-sm font-mono break-all">{createdToken}</code>
|
||
<button
|
||
onClick={() => copyToClipboard(createdToken)}
|
||
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition flex-shrink-0"
|
||
>
|
||
📋 Copiar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||
<p className="text-sm text-blue-900 font-semibold mb-2">Ejemplo de uso:</p>
|
||
<code className="text-xs text-blue-800 block">
|
||
curl -H "Authorization: Bearer {createdToken}" http://tu-api.com/api/inspections
|
||
</code>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCreatedToken(null)}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Formulario de Crear Token */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||
<h3 className="text-lg font-bold text-gray-900 mb-4">Generar Nuevo Token</h3>
|
||
|
||
<form onSubmit={handleCreateToken}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción (opcional)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newTokenDescription}
|
||
onChange={(e) => setNewTokenDescription(e.target.value)}
|
||
placeholder="ej: Integración con sistema X"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Te ayuda a identificar para qué usas este token
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={creating}
|
||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||
>
|
||
{creating ? 'Generando...' : 'Generar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Lista de Tokens */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando tokens...</div>
|
||
</div>
|
||
) : tokens.length === 0 ? (
|
||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||
<div className="text-4xl mb-3">🔑</div>
|
||
<p className="text-gray-600 mb-2">No tienes tokens API creados</p>
|
||
<p className="text-sm text-gray-500">Genera uno para acceder a la API sin login</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{tokens.map((token) => (
|
||
<div
|
||
key={token.id}
|
||
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
||
>
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-lg">🔑</span>
|
||
<h4 className="font-semibold text-gray-900">
|
||
{token.description || 'Token sin descripción'}
|
||
</h4>
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||
token.is_active
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-red-100 text-red-800'
|
||
}`}>
|
||
{token.is_active ? 'Activo' : 'Revocado'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
<div>
|
||
<span className="text-gray-500">Creado:</span>{' '}
|
||
{new Date(token.created_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
{token.last_used_at && (
|
||
<div>
|
||
<span className="text-gray-500">Último uso:</span>{' '}
|
||
{new Date(token.last_used_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
)}
|
||
{!token.last_used_at && (
|
||
<div className="text-yellow-600">
|
||
⚠️ Nunca usado
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{token.is_active && (
|
||
<button
|
||
onClick={() => handleRevokeToken(token.id)}
|
||
className="ml-4 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||
>
|
||
Revocar
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Información de Ayuda */}
|
||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<span className="text-blue-600 text-xl">ℹ️</span>
|
||
<div className="flex-1 text-sm text-blue-900">
|
||
<p className="font-semibold mb-2">¿Cómo usar los tokens API?</p>
|
||
<p className="mb-2">
|
||
Los tokens API te permiten acceder a todos los endpoints sin necesidad de hacer login.
|
||
Son perfectos para integraciones, scripts automatizados o aplicaciones externas.
|
||
</p>
|
||
<p className="mb-2">
|
||
Incluye el token en el header <code className="bg-blue-100 px-1 py-0.5 rounded">Authorization</code> de tus requests:
|
||
</p>
|
||
<code className="block bg-blue-100 p-2 rounded text-xs mt-2">
|
||
Authorization: Bearer AYUTEC_tu_token_aqui
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function QuestionsManagerModal({ checklist, onClose }) {
|
||
const [questions, setQuestions] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [editingQuestion, setEditingQuestion] = useState(null)
|
||
const [viewingAudit, setViewingAudit] = useState(null)
|
||
const [auditHistory, setAuditHistory] = useState([])
|
||
const [loadingAudit, setLoadingAudit] = useState(false)
|
||
const [draggedQuestion, setDraggedQuestion] = useState(null)
|
||
const [dragOverQuestion, setDragOverQuestion] = useState(null)
|
||
const scrollContainerRef = useRef(null)
|
||
const autoScrollIntervalRef = useRef(null)
|
||
const [formData, setFormData] = useState({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadQuestions()
|
||
}, [])
|
||
|
||
const loadQuestions = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setQuestions(data.questions || [])
|
||
}
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading questions:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadAuditHistory = async (questionId) => {
|
||
setLoadingAudit(true)
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${questionId}/audit`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAuditHistory(data)
|
||
setViewingAudit(questionId)
|
||
} else {
|
||
alert('Error al cargar historial')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading audit history:', error)
|
||
alert('Error al cargar historial')
|
||
} finally {
|
||
setLoadingAudit(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateQuestion = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
...formData,
|
||
checklist_id: checklist.id
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateForm(false)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al crear pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear pregunta')
|
||
}
|
||
}
|
||
|
||
const handleEditQuestion = (question) => {
|
||
setEditingQuestion(question)
|
||
setShowCreateForm(false)
|
||
setFormData({
|
||
section: question.section || '',
|
||
text: question.text,
|
||
type: question.type,
|
||
points: question.points || 1,
|
||
options: question.options || {
|
||
type: question.type,
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: question.allow_photos ?? true,
|
||
max_photos: question.max_photos || 3,
|
||
requires_comment_on_fail: question.requires_comment_on_fail || false,
|
||
send_notification: question.send_notification || false,
|
||
parent_question_id: question.parent_question_id || null,
|
||
show_if_answer: question.show_if_answer || '',
|
||
ai_prompt: question.ai_prompt || ''
|
||
})
|
||
}
|
||
|
||
const handleUpdateQuestion = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${editingQuestion.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
checklist_id: checklist.id,
|
||
section: formData.section,
|
||
text: formData.text,
|
||
type: formData.type,
|
||
points: parseInt(formData.points),
|
||
options: formData.options,
|
||
allow_photos: formData.allow_photos,
|
||
max_photos: parseInt(formData.max_photos),
|
||
requires_comment_on_fail: formData.requires_comment_on_fail,
|
||
send_notification: formData.send_notification,
|
||
parent_question_id: formData.parent_question_id || null,
|
||
show_if_answer: formData.show_if_answer || null,
|
||
ai_prompt: formData.ai_prompt || null
|
||
})
|
||
})
|
||
|
||
if (response.ok) {
|
||
setEditingQuestion(null)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al actualizar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar pregunta')
|
||
}
|
||
}
|
||
|
||
const handleDeleteQuestion = async (questionId) => {
|
||
if (!confirm('¿Estás seguro de eliminar esta pregunta?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${questionId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
alert('✅ Pregunta eliminada exitosamente')
|
||
} else {
|
||
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }))
|
||
|
||
if (response.status === 400) {
|
||
// Error de validación (pregunta con respuestas o subpreguntas)
|
||
alert(`⚠️ ${errorData.detail}`)
|
||
} else {
|
||
alert('❌ Error al eliminar pregunta')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('❌ Error de conexión al eliminar pregunta')
|
||
}
|
||
}
|
||
|
||
const moveQuestion = async (questionId, direction) => {
|
||
const questionsList = Object.values(questionsBySection).flat()
|
||
const currentIndex = questionsList.findIndex(q => q.id === questionId)
|
||
|
||
if (currentIndex === -1) return
|
||
|
||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
||
|
||
if (newIndex < 0 || newIndex >= questionsList.length) return
|
||
|
||
// Crear nueva lista con el orden actualizado
|
||
const newList = [...questionsList]
|
||
const [movedQuestion] = newList.splice(currentIndex, 1)
|
||
newList.splice(newIndex, 0, movedQuestion)
|
||
|
||
// Preparar datos para el backend
|
||
const reorderData = newList.map((q, index) => ({
|
||
question_id: q.id,
|
||
new_order: index
|
||
}))
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}/questions/reorder`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(reorderData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
}
|
||
|
||
// Drag & Drop handlers
|
||
const handleDragStart = (e, question) => {
|
||
setDraggedQuestion(question)
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.currentTarget.style.opacity = '0.5'
|
||
}
|
||
|
||
const handleDragEnd = (e) => {
|
||
e.currentTarget.style.opacity = '1'
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
|
||
// Limpiar auto-scroll
|
||
if (autoScrollIntervalRef.current) {
|
||
clearInterval(autoScrollIntervalRef.current)
|
||
autoScrollIntervalRef.current = null
|
||
}
|
||
}
|
||
|
||
const handleDragOver = (e, question) => {
|
||
e.preventDefault()
|
||
|
||
// Validar que ambas preguntas sean del mismo nivel (padre-padre o hijo-hijo del mismo padre)
|
||
if (draggedQuestion) {
|
||
const draggedIsChild = !!draggedQuestion.parent_question_id
|
||
const targetIsChild = !!question.parent_question_id
|
||
|
||
// No permitir mezclar niveles
|
||
if (draggedIsChild !== targetIsChild) {
|
||
e.dataTransfer.dropEffect = 'none'
|
||
return
|
||
}
|
||
|
||
// Si son hijos, deben ser del mismo padre
|
||
if (draggedIsChild && draggedQuestion.parent_question_id !== question.parent_question_id) {
|
||
e.dataTransfer.dropEffect = 'none'
|
||
return
|
||
}
|
||
}
|
||
|
||
e.dataTransfer.dropEffect = 'move'
|
||
setDragOverQuestion(question)
|
||
|
||
// Auto-scroll cuando está cerca de los bordes
|
||
if (scrollContainerRef.current) {
|
||
const container = scrollContainerRef.current
|
||
const rect = container.getBoundingClientRect()
|
||
const scrollThreshold = 100 // Pixeles desde el borde para activar scroll
|
||
const scrollSpeed = 10 // Velocidad de scroll
|
||
|
||
const mouseY = e.clientY
|
||
const distanceFromTop = mouseY - rect.top
|
||
const distanceFromBottom = rect.bottom - mouseY
|
||
|
||
// Limpiar intervalo anterior si existe
|
||
if (autoScrollIntervalRef.current) {
|
||
clearInterval(autoScrollIntervalRef.current)
|
||
autoScrollIntervalRef.current = null
|
||
}
|
||
|
||
// Scroll hacia arriba
|
||
if (distanceFromTop < scrollThreshold && container.scrollTop > 0) {
|
||
autoScrollIntervalRef.current = setInterval(() => {
|
||
if (container.scrollTop > 0) {
|
||
container.scrollTop -= scrollSpeed
|
||
} else {
|
||
clearInterval(autoScrollIntervalRef.current)
|
||
autoScrollIntervalRef.current = null
|
||
}
|
||
}, 16) // ~60fps
|
||
}
|
||
// Scroll hacia abajo
|
||
else if (distanceFromBottom < scrollThreshold &&
|
||
container.scrollTop < container.scrollHeight - container.clientHeight) {
|
||
autoScrollIntervalRef.current = setInterval(() => {
|
||
if (container.scrollTop < container.scrollHeight - container.clientHeight) {
|
||
container.scrollTop += scrollSpeed
|
||
} else {
|
||
clearInterval(autoScrollIntervalRef.current)
|
||
autoScrollIntervalRef.current = null
|
||
}
|
||
}, 16) // ~60fps
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleDragLeave = (e) => {
|
||
setDragOverQuestion(null)
|
||
|
||
// Limpiar auto-scroll si sale del área
|
||
if (autoScrollIntervalRef.current) {
|
||
clearInterval(autoScrollIntervalRef.current)
|
||
autoScrollIntervalRef.current = null
|
||
}
|
||
}
|
||
|
||
const handleDrop = async (e, targetQuestion) => {
|
||
e.preventDefault()
|
||
|
||
if (!draggedQuestion || draggedQuestion.id === targetQuestion.id) {
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Validar que sean del mismo nivel
|
||
const draggedIsChild = !!draggedQuestion.parent_question_id
|
||
const targetIsChild = !!targetQuestion.parent_question_id
|
||
|
||
if (draggedIsChild !== targetIsChild) {
|
||
alert('⚠️ Solo puedes reordenar preguntas del mismo nivel')
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Si son hijos, validar que sean del mismo padre
|
||
if (draggedIsChild && draggedQuestion.parent_question_id !== targetQuestion.parent_question_id) {
|
||
alert('⚠️ Solo puedes reordenar subpreguntas del mismo padre')
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Filtrar solo las preguntas del mismo nivel/grupo
|
||
let questionsList
|
||
if (draggedIsChild) {
|
||
// Solo subpreguntas del mismo padre
|
||
questionsList = questions.filter(q =>
|
||
q.parent_question_id === draggedQuestion.parent_question_id
|
||
)
|
||
} else {
|
||
// Solo preguntas padre (sin parent_question_id)
|
||
questionsList = questions.filter(q => !q.parent_question_id)
|
||
}
|
||
|
||
const draggedIndex = questionsList.findIndex(q => q.id === draggedQuestion.id)
|
||
const targetIndex = questionsList.findIndex(q => q.id === targetQuestion.id)
|
||
|
||
// Crear nueva lista con el orden actualizado
|
||
const newList = [...questionsList]
|
||
const [movedQuestion] = newList.splice(draggedIndex, 1)
|
||
newList.splice(targetIndex, 0, movedQuestion)
|
||
|
||
// Preparar datos para el backend (solo las preguntas afectadas)
|
||
const reorderData = newList.map((q, index) => ({
|
||
question_id: q.id,
|
||
new_order: index
|
||
}))
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}/questions/reorder`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(reorderData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
}
|
||
|
||
const questionsBySection = questions.reduce((acc, q) => {
|
||
const section = q.section || 'Sin sección'
|
||
if (!acc[section]) acc[section] = []
|
||
acc[section].push(q)
|
||
return acc
|
||
}, {})
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-purple-600 text-white p-6 rounded-t-lg">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Gestionar Preguntas</h2>
|
||
<p className="mt-1 opacity-90">{checklist.name}</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<div>
|
||
<p className="text-sm text-gray-600">
|
||
Total de preguntas: <strong>{questions.length}</strong> |
|
||
Puntuación máxima: <strong>{questions.reduce((sum, q) => sum + (q.points || 0), 0)}</strong>
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateForm(!showCreateForm)
|
||
setEditingQuestion(null)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
}}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
{showCreateForm || editingQuestion ? 'Cancelar' : '+ Nueva Pregunta'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Create/Edit Form */}
|
||
{(showCreateForm || editingQuestion) && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||
<h3 className="text-lg font-semibold text-purple-900 mb-4">
|
||
{editingQuestion ? '✏️ Editar Pregunta' : '➕ Nueva Pregunta'}
|
||
</h3>
|
||
<form onSubmit={editingQuestion ? handleUpdateQuestion : handleCreateQuestion} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Sección
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.section}
|
||
onChange={(e) => setFormData({ ...formData, section: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Motor, Frenos, Documentación"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Puntos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={formData.points}
|
||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) || 1 })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Texto de la pregunta *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.text}
|
||
onChange={(e) => setFormData({ ...formData, text: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Estado de las pastillas de freno"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Configuración del Tipo de Pregunta */}
|
||
<div className="bg-white border-2 border-purple-300 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-purple-900 mb-3">📝 Configuración de la Pregunta</h4>
|
||
<QuestionTypeEditor
|
||
value={formData.options || null}
|
||
onChange={(config) => {
|
||
setFormData({
|
||
...formData,
|
||
type: config.type,
|
||
options: config
|
||
})
|
||
}}
|
||
maxPoints={formData.points}
|
||
/>
|
||
</div>
|
||
|
||
{/* Pregunta Condicional - Subpreguntas Anidadas hasta 5 niveles */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||
⚡ Pregunta Condicional - Subpreguntas Anidadas (hasta 5 niveles)
|
||
</h4>
|
||
{/* Solo permitir condicionales para boolean y single_choice */}
|
||
{(() => {
|
||
const currentType = formData.options?.type || formData.type
|
||
const allowConditional = ['boolean', 'single_choice'].includes(currentType)
|
||
|
||
if (!allowConditional) {
|
||
return (
|
||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||
<p className="text-sm text-gray-600">
|
||
ℹ️ Las preguntas condicionales solo están disponibles para tipos <strong>Boolean</strong> y <strong>Opción Única</strong>
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Pregunta padre
|
||
</label>
|
||
<select
|
||
value={formData.parent_question_id || ''}
|
||
onChange={(e) => setFormData({
|
||
...formData,
|
||
parent_question_id: e.target.value ? parseInt(e.target.value) : null,
|
||
show_if_answer: '' // Reset al cambiar padre
|
||
})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
>
|
||
<option value="">Ninguna (pregunta principal)</option>
|
||
{questions
|
||
.filter(q => {
|
||
// Permitir cualquier pregunta que no sea esta misma
|
||
// y que tenga depth_level < 5 (para no exceder límite)
|
||
const depth = q.depth_level || 0
|
||
return depth < 5
|
||
})
|
||
.map(q => {
|
||
const depth = q.depth_level || 0
|
||
const indent = ' '.repeat(depth)
|
||
const levelLabel = depth > 0 ? ` [Nivel ${depth}]` : ''
|
||
return (
|
||
<option key={q.id} value={q.id}>
|
||
{indent}#{q.id} - {q.text.substring(0, 40)}{q.text.length > 40 ? '...' : ''}{levelLabel}
|
||
</option>
|
||
)
|
||
})
|
||
}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Esta pregunta aparecerá solo si se cumple la condición de la pregunta padre
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Mostrar si la respuesta es:
|
||
</label>
|
||
<select
|
||
value={formData.show_if_answer}
|
||
onChange={(e) => setFormData({ ...formData, show_if_answer: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
disabled={!formData.parent_question_id}
|
||
>
|
||
<option value="">Seleccione...</option>
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
if (!parentQ) return null
|
||
|
||
// Leer opciones del nuevo formato
|
||
const config = parentQ.options || {}
|
||
const parentType = config.type || parentQ.type
|
||
|
||
// Para boolean o single_choice, mostrar las opciones configuradas
|
||
if ((parentType === 'boolean' || parentType === 'single_choice') && config.choices) {
|
||
return config.choices.map((choice, idx) => (
|
||
<option key={idx} value={choice.value}>
|
||
{choice.label}
|
||
</option>
|
||
))
|
||
}
|
||
|
||
// Compatibilidad con tipos antiguos
|
||
if (parentType === 'pass_fail') {
|
||
return [
|
||
<option key="pass" value="pass">✓ Pasa</option>,
|
||
<option key="fail" value="fail">✗ Falla</option>
|
||
]
|
||
} else if (parentType === 'good_bad') {
|
||
return [
|
||
<option key="good" value="good">✓ Bueno</option>,
|
||
<option key="bad" value="bad">✗ Malo</option>
|
||
]
|
||
}
|
||
|
||
return <option disabled>Tipo de pregunta no compatible</option>
|
||
})()}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
La pregunta solo se mostrará con esta respuesta específica
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Indicador de profundidad */}
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
const parentDepth = parentQ?.depth_level || 0
|
||
const newDepth = parentDepth + 1
|
||
|
||
return (
|
||
<div className={`mt-3 p-2 rounded ${newDepth >= 5 ? 'bg-red-50 border border-red-200' : 'bg-blue-100'}`}>
|
||
<p className="text-xs">
|
||
📊 <strong>Profundidad:</strong> Nivel {newDepth} de 5 máximo
|
||
{newDepth >= 5 && ' ⚠️ Máximo alcanzado'}
|
||
</p>
|
||
</div>
|
||
)
|
||
})()}
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-purple-900 mb-3">🤖 Prompt de IA (opcional)</h4>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Prompt personalizado para análisis de fotos
|
||
</label>
|
||
<textarea
|
||
value={formData.ai_prompt}
|
||
onChange={(e) => setFormData({ ...formData, ai_prompt: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
rows="3"
|
||
placeholder="Ej: Analiza si las luces delanteras están encendidas y funcionando correctamente. Verifica que ambas luces tengan brillo uniforme y no presenten daños visibles."
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Este prompt guiará a la IA para analizar las fotos específicamente para esta pregunta. Si la foto no corresponde al contexto, la IA sugerirá cambiarla.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Puntos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={formData.points}
|
||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.allow_photos}
|
||
onChange={(e) => setFormData({ ...formData, allow_photos: e.target.checked })}
|
||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Permitir fotos
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Máx. fotos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
value={formData.max_photos}
|
||
onChange={(e) => setFormData({ ...formData, max_photos: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
disabled={!formData.allow_photos}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.send_notification}
|
||
onChange={(e) => setFormData({ ...formData, send_notification: e.target.checked })}
|
||
className="w-4 h-4 text-yellow-600 border-gray-300 rounded focus:ring-yellow-500"
|
||
/>
|
||
<label className="text-sm font-medium text-gray-700">
|
||
🔔 Enviar notificación cuando se responda esta pregunta
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mt-2 ml-6">
|
||
Si activas esta opción, se enviará una notificación automática al administrador cada vez que un mecánico responda esta pregunta.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
{editingQuestion ? 'Actualizar Pregunta' : 'Crear Pregunta'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Questions List */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando preguntas...</div>
|
||
</div>
|
||
) : questions.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No hay preguntas en este checklist</p>
|
||
<p className="text-sm text-gray-400 mt-2">Crea la primera pregunta para comenzar</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{Object.entries(questionsBySection).map(([section, sectionQuestions]) => (
|
||
<div key={section} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3">
|
||
<h3 className="font-semibold text-gray-900">{section}</h3>
|
||
<p className="text-sm text-gray-600">
|
||
{sectionQuestions.length} preguntas | {sectionQuestions.reduce((sum, q) => sum + (q.points || 0), 0)} puntos
|
||
</p>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{sectionQuestions.map((question) => {
|
||
const isSubQuestion = question.parent_question_id
|
||
const parentQuestion = isSubQuestion ? questions.find(q => q.id === question.parent_question_id) : null
|
||
|
||
return (
|
||
<div
|
||
key={question.id}
|
||
draggable={true}
|
||
onDragStart={(e) => handleDragStart(e, question)}
|
||
onDragEnd={handleDragEnd}
|
||
onDragOver={(e) => handleDragOver(e, question)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => handleDrop(e, question)}
|
||
className={`p-4 hover:bg-gray-50 flex justify-between items-start cursor-move transition-all ${
|
||
isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : ''
|
||
} ${
|
||
dragOverQuestion?.id === question.id ? 'border-t-4 border-indigo-500' : ''
|
||
}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
{isSubQuestion && (
|
||
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
|
||
⚡ Condicional
|
||
</span>
|
||
)}
|
||
<p className="text-gray-900">{question.text}</p>
|
||
</div>
|
||
{isSubQuestion && parentQuestion && (
|
||
<p className="text-xs text-blue-600 mt-1">
|
||
→ Aparece si <strong>#{question.parent_question_id}</strong> es <strong>{question.show_if_answer}</strong>
|
||
</p>
|
||
)}
|
||
<div className="flex gap-4 mt-2 text-sm text-gray-600">
|
||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{question.type}
|
||
</span>
|
||
<span>{question.points} pts</span>
|
||
{question.allow_photos && (
|
||
<span>📷 Máx {question.max_photos} fotos</span>
|
||
)}
|
||
{question.send_notification && (
|
||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
||
🔔 Notificación
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="ml-4 flex gap-2 items-center">
|
||
{/* Indicador de drag */}
|
||
<div className="text-gray-400 hover:text-gray-600 cursor-move px-2" title="Arrastra para reordenar">
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z"></path>
|
||
</svg>
|
||
</div>
|
||
<div className="h-8 w-px bg-gray-300"></div>
|
||
<button
|
||
onClick={() => loadAuditHistory(question.id)}
|
||
className="text-gray-600 hover:text-gray-700 text-sm"
|
||
title="Ver historial de cambios"
|
||
>
|
||
📜 Historial
|
||
</button>
|
||
<button
|
||
onClick={() => handleEditQuestion(question)}
|
||
className="text-blue-600 hover:text-blue-700 text-sm"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteQuestion(question.id)}
|
||
className="text-red-600 hover:text-red-700 text-sm"
|
||
>
|
||
🗑️ Eliminar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Audit History Modal */}
|
||
{viewingAudit && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-gray-700 text-white p-4 rounded-t-lg flex justify-between items-center">
|
||
<h3 className="text-lg font-bold">📜 Historial de Cambios - Pregunta #{viewingAudit}</h3>
|
||
<button
|
||
onClick={() => {
|
||
setViewingAudit(null)
|
||
setAuditHistory([])
|
||
}}
|
||
className="text-white hover:bg-gray-600 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loadingAudit ? (
|
||
<div className="text-center py-8 text-gray-500">Cargando historial...</div>
|
||
) : auditHistory.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500">No hay cambios registrados</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{auditHistory.map((log) => (
|
||
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||
log.action === 'created' ? 'bg-green-100 text-green-800' :
|
||
log.action === 'updated' ? 'bg-blue-100 text-blue-800' :
|
||
'bg-red-100 text-red-800'
|
||
}`}>
|
||
{log.action === 'created' ? '➕ Creado' :
|
||
log.action === 'updated' ? '✏️ Modificado' :
|
||
'🗑️ Eliminado'}
|
||
</span>
|
||
{log.field_name && (
|
||
<span className="text-sm text-gray-600">
|
||
Campo: <strong>{log.field_name}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="text-xs text-gray-500">
|
||
{new Date(log.created_at).toLocaleString('es-PY', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
{log.field_name && (
|
||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||
<div className="bg-red-50 border border-red-200 rounded p-2 text-sm">
|
||
{log.old_value || '-'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||
<div className="bg-green-50 border border-green-200 rounded p-2 text-sm">
|
||
{log.new_value || '-'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!log.field_name && (log.old_value || log.new_value) && (
|
||
<div className="mt-2 text-sm text-gray-700">
|
||
{log.old_value || log.new_value}
|
||
</div>
|
||
)}
|
||
|
||
{log.comment && (
|
||
<div className="mt-2 text-sm text-gray-600 italic">
|
||
💬 {log.comment}
|
||
</div>
|
||
)}
|
||
|
||
{log.user && (
|
||
<div className="mt-2 text-xs text-gray-500">
|
||
Por: {log.user.full_name || log.user.username}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={() => {
|
||
setViewingAudit(null)
|
||
setAuditHistory([])
|
||
}}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection }) {
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [showQuestionsModal, setShowQuestionsModal] = useState(false)
|
||
const [showEditPermissionsModal, setShowEditPermissionsModal] = useState(false)
|
||
const [showEditChecklistModal, setShowEditChecklistModal] = useState(false)
|
||
const [showLogoModal, setShowLogoModal] = useState(false)
|
||
const [selectedChecklist, setSelectedChecklist] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
const [updating, setUpdating] = useState(false)
|
||
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||
const [mechanics, setMechanics] = useState([])
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [aiModeFilter, setAiModeFilter] = useState('all') // all, off, optional, required
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
const [editChecklistData, setEditChecklistData] = useState({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true
|
||
})
|
||
const [editPermissionsData, setEditPermissionsData] = useState({
|
||
mechanic_ids: []
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadMechanics()
|
||
}, [])
|
||
|
||
const loadMechanics = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/users`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// Filtrar solo mecánicos activos
|
||
const mechanicUsers = data.filter(u =>
|
||
(u.role === 'mechanic' || u.role === 'mecanico') && u.is_active
|
||
)
|
||
setMechanics(mechanicUsers)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading mechanics:', error)
|
||
}
|
||
}
|
||
|
||
// Filtrar checklists
|
||
const filteredChecklists = checklists.filter(checklist => {
|
||
const matchesSearch =
|
||
checklist.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
checklist.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
checklist.id?.toString().includes(searchTerm)
|
||
|
||
const matchesAiMode =
|
||
aiModeFilter === 'all' || checklist.ai_mode === aiModeFilter
|
||
|
||
return matchesSearch && matchesAiMode
|
||
})
|
||
|
||
// Calcular paginación
|
||
const totalPages = Math.ceil(filteredChecklists.length / itemsPerPage)
|
||
const startIndex = (currentPage - 1) * itemsPerPage
|
||
const endIndex = startIndex + itemsPerPage
|
||
const paginatedChecklists = filteredChecklists.slice(startIndex, endIndex)
|
||
|
||
// Reset a página 1 cuando cambian los filtros
|
||
useEffect(() => {
|
||
setCurrentPage(1)
|
||
}, [searchTerm, aiModeFilter])
|
||
|
||
const handleCreate = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
onChecklistCreated()
|
||
} else {
|
||
alert('Error al crear checklist')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear checklist')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleEditPermissions = async (e) => {
|
||
e.preventDefault()
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(editPermissionsData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
onChecklistCreated() // Reload checklists
|
||
} else {
|
||
alert('Error al actualizar permisos')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar permisos')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleEditChecklist = async (e) => {
|
||
e.preventDefault()
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(editChecklistData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowEditChecklistModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditChecklistData({ name: '', description: '', ai_mode: 'off', scoring_enabled: true })
|
||
onChecklistCreated() // Reload checklists
|
||
} else {
|
||
alert('Error al actualizar checklist')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar checklist')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleUploadLogo = async (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
// Validar que sea imagen
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('Por favor selecciona una imagen válida')
|
||
return
|
||
}
|
||
|
||
setUploadingLogo(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/upload-logo`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated() // Reload checklists
|
||
alert('Logo subido exitosamente')
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al subir el logo')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al subir el logo')
|
||
} finally {
|
||
setUploadingLogo(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteLogo = async () => {
|
||
if (!confirm('¿Estás seguro de eliminar el logo?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/logo`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated() // Reload checklists
|
||
alert('Logo eliminado exitosamente')
|
||
} else {
|
||
alert('Error al eliminar el logo')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al eliminar el logo')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{user.role === 'admin' && (
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Crear Checklist
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Buscador y Filtros */}
|
||
{checklists.length > 0 && (
|
||
<div className="mb-6 space-y-4">
|
||
<div className="flex gap-4 flex-wrap">
|
||
{/* Buscador */}
|
||
<div className="flex-1 min-w-[300px]">
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar por nombre, descripción o ID..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
{/* Filtro de Modo IA */}
|
||
<select
|
||
value={aiModeFilter}
|
||
onChange={(e) => setAiModeFilter(e.target.value)}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="all">Todos los modos IA</option>
|
||
<option value="off">Sin IA</option>
|
||
<option value="optional">IA Opcional</option>
|
||
<option value="required">IA Requerida</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Contador de resultados */}
|
||
<div className="text-sm text-gray-600">
|
||
Mostrando {startIndex + 1}-{Math.min(endIndex, filteredChecklists.length)} de {filteredChecklists.length} checklists
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{checklists.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
{user.role === 'admin' ? (
|
||
<>
|
||
<p className="text-gray-500">No hay checklists activos</p>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Crear tu primer checklist
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="text-4xl mb-3">🔒</div>
|
||
<p className="text-gray-700 font-semibold">No tienes checklists disponibles</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
Contacta con el administrador para que te asigne permisos a los checklists que necesites usar.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : filteredChecklists.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No se encontraron checklists con los filtros aplicados</p>
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm('')
|
||
setAiModeFilter('all')
|
||
}}
|
||
className="mt-4 text-blue-600 hover:text-blue-700 underline"
|
||
>
|
||
Limpiar filtros
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{paginatedChecklists.map((checklist) => (
|
||
<div key={checklist.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex justify-between items-start gap-4">
|
||
{/* Logo del Checklist */}
|
||
<div className="flex-shrink-0">
|
||
{checklist.logo_url ? (
|
||
<img
|
||
src={checklist.logo_url}
|
||
alt={`Logo ${checklist.name}`}
|
||
className="w-16 h-16 object-contain rounded-lg border border-gray-200"
|
||
/>
|
||
) : (
|
||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-lg flex items-center justify-center">
|
||
<span className="text-2xl">📋</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-gray-900">{checklist.name}</h3>
|
||
<p className="text-sm text-gray-600 mt-1">{checklist.description}</p>
|
||
<div className="flex gap-4 mt-3 text-sm">
|
||
<span className="text-gray-500">
|
||
Puntuación máxima: <strong>{checklist.max_score}</strong>
|
||
</span>
|
||
<span className="text-gray-500">
|
||
Modo IA: <strong className="capitalize">{checklist.ai_mode}</strong>
|
||
</span>
|
||
</div>
|
||
{/* Mostrar permisos de mecánicos */}
|
||
{user.role === 'admin' && (
|
||
<div className="mt-2">
|
||
{!checklist.allowed_mechanics || checklist.allowed_mechanics.length === 0 ? (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||
🌍 Acceso Global - Todos los mecánicos
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||
🔐 Restringido - {checklist.allowed_mechanics.length} mecánico{checklist.allowed_mechanics.length !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{user.role === 'admin' && (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setEditChecklistData({
|
||
name: checklist.name,
|
||
description: checklist.description || '',
|
||
ai_mode: checklist.ai_mode || 'off',
|
||
scoring_enabled: checklist.scoring_enabled ?? true
|
||
})
|
||
setShowEditChecklistModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-indigo-500 to-purple-500 text-white rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Editar checklist"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setShowLogoModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg hover:from-blue-600 hover:to-cyan-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Gestionar logo"
|
||
>
|
||
🖼️ Logo
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setEditPermissionsData({
|
||
mechanic_ids: checklist.allowed_mechanics || []
|
||
})
|
||
setShowEditPermissionsModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Editar permisos"
|
||
>
|
||
🔐 Permisos
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setShowQuestionsModal(true)
|
||
}}
|
||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Gestionar Preguntas
|
||
</button>
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
</>
|
||
)}
|
||
{(user.role === 'mechanic' || user.role === 'mecanico') && (
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Controles de paginación */}
|
||
{filteredChecklists.length > itemsPerPage && (
|
||
<div className="flex items-center justify-center gap-2 mt-6">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
← Anterior
|
||
</button>
|
||
|
||
<div className="flex gap-1">
|
||
{[...Array(totalPages)].map((_, index) => {
|
||
const page = index + 1
|
||
// Mostrar solo páginas cercanas a la actual
|
||
if (
|
||
page === 1 ||
|
||
page === totalPages ||
|
||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||
) {
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-3 py-2 rounded-lg ${
|
||
currentPage === page
|
||
? 'bg-blue-600 text-white'
|
||
: 'border border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
} else if (page === currentPage - 2 || page === currentPage + 2) {
|
||
return <span key={page} className="px-2 py-2">...</span>
|
||
}
|
||
return null
|
||
})}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Siguiente →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Modal Gestionar Preguntas */}
|
||
{showQuestionsModal && selectedChecklist && (
|
||
<QuestionsManagerModal
|
||
checklist={selectedChecklist}
|
||
onClose={() => {
|
||
setShowQuestionsModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Modal Crear Checklist */}
|
||
{showCreateModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Crear Nuevo Checklist</h2>
|
||
|
||
<form onSubmit={handleCreate} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nombre del Checklist *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Ej: Mantenimiento Preventivo"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción
|
||
</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Descripción del checklist..."
|
||
rows="3"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Modo de Asistencia
|
||
</label>
|
||
<select
|
||
value={formData.ai_mode}
|
||
onChange={(e) => setFormData({ ...formData, ai_mode: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="off">🚫 Manual - Control total del operario</option>
|
||
<option value="assisted">🤝 Asistido - Sugerencias automáticas</option>
|
||
<option value="full">🤖 Automático - Análisis completo</option>
|
||
</select>
|
||
|
||
{/* Descripción del modo seleccionado */}
|
||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||
{formData.ai_mode === 'off' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>Modo Manual:</strong> El operario completa manualmente todas las respuestas.
|
||
Sin dependencia de internet o sistemas externos.
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'assisted' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>Modo Asistido:</strong> Cuando se suben fotos, el sistema analiza y sugiere
|
||
estado, criticidad y observaciones. El operario acepta o modifica.
|
||
<div className="mt-1 text-xs">⚠️ Requiere configuración de API externa</div>
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'full' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>Modo Automático:</strong> El operario solo toma fotos y el sistema responde
|
||
automáticamente todas las preguntas. Ideal para inspecciones rápidas masivas.
|
||
<div className="mt-1 text-xs">⚠️ Requiere configuración de API externa</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.scoring_enabled}
|
||
onChange={(e) => setFormData({ ...formData, scoring_enabled: e.target.checked })}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Habilitar sistema de puntuación
|
||
</label>
|
||
</div>
|
||
|
||
{/* Selector de Mecánicos Autorizados */}
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({ ...formData, mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: [...formData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: formData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||
<p className="text-sm text-yellow-800">
|
||
ℹ️ Después de crear el checklist, podrás agregar preguntas desde la API o directamente en la base de datos.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={creating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
disabled={creating}
|
||
>
|
||
{creating ? 'Creando...' : 'Crear Checklist'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Editar Checklist */}
|
||
{showEditChecklistModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">✏️ Editar Checklist</h2>
|
||
|
||
<form onSubmit={handleEditChecklist} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre del Checklist *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editChecklistData.name}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, name: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
placeholder="Ej: Inspección Pre-entrega"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Descripción
|
||
</label>
|
||
<textarea
|
||
value={editChecklistData.description}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, description: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
rows="3"
|
||
placeholder="Descripción del checklist..."
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Modo IA
|
||
</label>
|
||
<select
|
||
value={editChecklistData.ai_mode}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, ai_mode: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="off">❌ Desactivado (Manual)</option>
|
||
<option value="assisted">💡 Asistido (Sugerencias)</option>
|
||
<option value="copilot">🤖 Copiloto (Auto-completar)</option>
|
||
</select>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
{editChecklistData.ai_mode === 'off' && '❌ El mecánico completa todo manualmente'}
|
||
{editChecklistData.ai_mode === 'assisted' && '💡 IA sugiere respuestas, el mecánico confirma'}
|
||
{editChecklistData.ai_mode === 'copilot' && '🤖 IA completa automáticamente, el mecánico revisa'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editChecklistData.scoring_enabled}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, scoring_enabled: e.target.checked })}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Habilitar sistema de puntuación
|
||
</label>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<p className="text-sm text-blue-800">
|
||
ℹ️ Los cambios se aplicarán inmediatamente. Las inspecciones existentes no se verán afectadas.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditChecklistModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditChecklistData({ name: '', description: '', ai_mode: 'off', scoring_enabled: true })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={updating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||
disabled={updating}
|
||
>
|
||
{updating ? 'Guardando...' : 'Guardar Cambios'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Editar Permisos */}
|
||
{showEditPermissionsModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Editar Permisos de Checklist</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{selectedChecklist.name}
|
||
</p>
|
||
|
||
<form onSubmit={handleEditPermissions} className="space-y-4">
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-96 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({
|
||
mechanic_ids: [...editPermissionsData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setEditPermissionsData({
|
||
mechanic_ids: editPermissionsData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<p className="text-sm text-blue-800">
|
||
ℹ️ Los cambios se aplicarán inmediatamente. Los mecánicos que pierdan acceso ya no verán este checklist.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={updating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition disabled:opacity-50"
|
||
disabled={updating}
|
||
>
|
||
{updating ? 'Guardando...' : 'Guardar Permisos'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Gestionar Logo */}
|
||
{showLogoModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-md w-full">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Gestionar Logo</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{selectedChecklist.name}
|
||
</p>
|
||
|
||
{/* Logo actual */}
|
||
<div className="mb-6 flex justify-center">
|
||
{selectedChecklist.logo_url ? (
|
||
<div className="relative">
|
||
<img
|
||
src={selectedChecklist.logo_url}
|
||
alt="Logo actual"
|
||
className="w-32 h-32 object-contain rounded-lg border-2 border-gray-200"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-lg flex items-center justify-center">
|
||
<span className="text-4xl">📋</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Botones de acción */}
|
||
<div className="space-y-3">
|
||
<label className="block">
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleUploadLogo}
|
||
className="hidden"
|
||
disabled={uploadingLogo}
|
||
/>
|
||
<div className="px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-center cursor-pointer">
|
||
{uploadingLogo ? 'Subiendo...' : selectedChecklist.logo_url ? '🔄 Cambiar Logo' : '📤 Subir Logo'}
|
||
</div>
|
||
</label>
|
||
|
||
{selectedChecklist.logo_url && (
|
||
<button
|
||
onClick={handleDeleteLogo}
|
||
className="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||
>
|
||
🗑️ Eliminar Logo
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
}}
|
||
className="w-full px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
|
||
<p className="mt-4 text-xs text-gray-500 text-center">
|
||
💡 Tamaño recomendado: 200x200px o similar. Formatos: JPG, PNG, SVG
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InspectionDetailModal({ inspection, user, onClose, onUpdate, onContinue }) {
|
||
const [loading, setLoading] = useState(true)
|
||
const [inspectionDetail, setInspectionDetail] = useState(null)
|
||
const [isInactivating, setIsInactivating] = useState(false)
|
||
const [editingAnswerId, setEditingAnswerId] = useState(null)
|
||
const [editFormData, setEditFormData] = useState({})
|
||
const [showAuditLog, setShowAuditLog] = useState(false)
|
||
const [auditLogs, setAuditLogs] = useState([])
|
||
const [loadingAudit, setLoadingAudit] = useState(false)
|
||
|
||
// Función helper para convertir valores técnicos a etiquetas legibles
|
||
const getReadableAnswer = (answerValue, questionOptions) => {
|
||
if (!answerValue || !questionOptions) {
|
||
return answerValue || 'Sin respuesta'
|
||
}
|
||
|
||
const config = questionOptions
|
||
const questionType = config.type || ''
|
||
|
||
// Para tipos con choices (boolean, single_choice, multiple_choice)
|
||
if (['boolean', 'single_choice', 'multiple_choice'].includes(questionType) && config.choices) {
|
||
// Si es multiple_choice, puede tener varios valores separados por coma
|
||
if (questionType === 'multiple_choice' && answerValue.includes(',')) {
|
||
const values = answerValue.split(',')
|
||
const labels = values.map(val => {
|
||
val = val.trim()
|
||
const choice = config.choices.find(c => c.value === val)
|
||
return choice ? choice.label : val
|
||
})
|
||
return labels.join(', ')
|
||
} else {
|
||
// Buscar la etiqueta correspondiente al valor
|
||
const choice = config.choices.find(c => c.value === answerValue)
|
||
if (choice) {
|
||
return choice.label
|
||
}
|
||
}
|
||
}
|
||
|
||
// Para tipos scale, text, number, date, time - devolver el valor tal cual
|
||
return answerValue
|
||
}
|
||
|
||
useEffect(() => {
|
||
const loadInspectionDetails = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Cargar la inspección completa con respuestas y checklist
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log('Inspection detail loaded:', data)
|
||
setInspectionDetail(data)
|
||
} else {
|
||
console.error('Error loading inspection:', response.status)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading inspection details:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
loadInspectionDetails()
|
||
}, [inspection.id, inspection.checklist_id])
|
||
|
||
const getStatusBadge = (status) => {
|
||
const statusConfig = {
|
||
ok: { bg: 'bg-green-100', text: 'text-green-800', label: '✓ OK' },
|
||
critical: { bg: 'bg-red-100', text: 'text-red-800', label: '✗ Crítico' },
|
||
minor: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '⚠ Menor' },
|
||
na: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'N/A' }
|
||
}
|
||
const config = statusConfig[status] || statusConfig.ok
|
||
return (
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${config.bg} ${config.text}`}>
|
||
{config.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const getCategoryIcon = (category) => {
|
||
const icons = {
|
||
'Motor': '🔧',
|
||
'Frenos': '🛑',
|
||
'Suspensión': '⚙️',
|
||
'Neumáticos': '🚗',
|
||
'Electricidad': '⚡',
|
||
'Carrocería': '🚙',
|
||
'Interior': '🪑',
|
||
'Documentación': '📄'
|
||
}
|
||
return icons[category] || '📋'
|
||
}
|
||
|
||
const loadAuditLog = async () => {
|
||
setLoadingAudit(true)
|
||
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}/audit-log`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAuditLogs(data)
|
||
setShowAuditLog(true)
|
||
} else {
|
||
alert('Error al cargar historial de cambios')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading audit log:', error)
|
||
alert('Error al cargar historial de cambios')
|
||
} finally {
|
||
setLoadingAudit(false)
|
||
}
|
||
}
|
||
|
||
const startEditAnswer = (answer) => {
|
||
setEditingAnswerId(answer.id)
|
||
setEditFormData({
|
||
answer_value: answer.answer_value || '',
|
||
status: answer.status || 'ok',
|
||
comment: answer.comment || '',
|
||
is_flagged: answer.is_flagged || false,
|
||
edit_comment: ''
|
||
})
|
||
}
|
||
|
||
const cancelEdit = () => {
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
}
|
||
|
||
const saveEdit = async (answerId) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/answers/${answerId}/admin-edit`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(editFormData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
// Recargar detalles de inspección
|
||
const inspectionResponse = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (inspectionResponse.ok) {
|
||
const data = await inspectionResponse.json()
|
||
setInspectionDetail(data)
|
||
}
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
alert('Respuesta actualizada correctamente')
|
||
} else {
|
||
alert('Error al actualizar respuesta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving edit:', error)
|
||
alert('Error al guardar cambios')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-blue-600 text-white p-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Inspección #{inspection.id}</h2>
|
||
<p className="mt-1 opacity-90">
|
||
{inspection.vehicle_plate} - {inspection.vehicle_brand} {inspection.vehicle_model}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-blue-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Info Cards */}
|
||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">OR Number</div>
|
||
<div className="text-lg font-bold">{inspection.or_number || 'N/A'}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Kilometraje</div>
|
||
<div className="text-lg font-bold">{inspection.vehicle_km} km</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Score Total</div>
|
||
<div className="text-lg font-bold">{inspection.score}/{inspection.max_score}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Porcentaje</div>
|
||
<div className="text-lg font-bold">{inspection.percentage.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando detalles...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Order Info */}
|
||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||
<h3 className="font-semibold text-gray-900 mb-2">Información del Pedido</h3>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-600">Nº de Pedido:</span>
|
||
<span className="ml-2 font-medium">{inspection.order_number || 'N/A'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">Fecha:</span>
|
||
<span className="ml-2 font-medium">
|
||
{inspection.started_at ? new Date(inspection.started_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}) : 'N/A'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Debug Info */}
|
||
{inspectionDetail && (
|
||
<div className="bg-gray-100 rounded p-2 mb-4 text-xs">
|
||
<strong>Debug:</strong> Respuestas cargadas: {inspectionDetail.answers?.length || 0},
|
||
Preguntas disponibles: {inspectionDetail.checklist?.questions?.length || 0}
|
||
</div>
|
||
)}
|
||
|
||
{/* Answers by Category */}
|
||
{inspectionDetail && inspectionDetail.checklist && inspectionDetail.checklist.questions && (
|
||
<div className="space-y-6">
|
||
{Object.entries(
|
||
inspectionDetail.checklist.questions.reduce((acc, question) => {
|
||
const category = question.category || 'Sin categoría'
|
||
if (!acc[category]) acc[category] = []
|
||
acc[category].push(question)
|
||
return acc
|
||
}, {})
|
||
).map(([category, categoryQuestions]) => (
|
||
<div key={category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3 flex items-center gap-2">
|
||
<span className="text-2xl">{getCategoryIcon(category)}</span>
|
||
<h3 className="font-semibold text-gray-900">{category}</h3>
|
||
<span className="ml-auto text-sm text-gray-600">
|
||
{categoryQuestions.length} preguntas
|
||
</span>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{categoryQuestions.map((question) => {
|
||
const answer = inspectionDetail.answers?.find(a => a.question_id === question.id)
|
||
return (
|
||
<div key={question.id} className="p-4 hover:bg-gray-50">
|
||
<div className="flex justify-between items-start gap-4">
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<p className="text-gray-900 font-medium">{question.text}</p>
|
||
{question.description && (
|
||
<p className="text-sm text-gray-500 mt-1">{question.description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{answer && (
|
||
<div className="mt-3 ml-10 space-y-2">
|
||
{editingAnswerId === answer.id ? (
|
||
// Modo Edición (solo admin)
|
||
<div className="bg-blue-50 border border-blue-300 rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-blue-700 font-semibold">✏️ Editando Respuesta</span>
|
||
</div>
|
||
|
||
{/* Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||
<select
|
||
value={editFormData.status}
|
||
onChange={(e) => setEditFormData({...editFormData, status: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="ok">✓ OK</option>
|
||
<option value="warning">⚠ Advertencia</option>
|
||
<option value="critical">✗ Crítico</option>
|
||
<option value="na">N/A</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Answer Value - Usar el componente visual adecuado según tipo de pregunta */}
|
||
{question.type !== 'pass_fail' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Valor de Respuesta</label>
|
||
<QuestionAnswerInput
|
||
question={question}
|
||
value={editFormData.answer_value}
|
||
onChange={(newValue) => setEditFormData({...editFormData, answer_value: newValue})}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Comment */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Observación</label>
|
||
<textarea
|
||
value={editFormData.comment}
|
||
onChange={(e) => setEditFormData({...editFormData, comment: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
/>
|
||
</div>
|
||
|
||
{/* Flagged */}
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editFormData.is_flagged}
|
||
onChange={(e) => setEditFormData({...editFormData, is_flagged: e.target.checked})}
|
||
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">🚩 Marcar como señalado</label>
|
||
</div>
|
||
|
||
{/* Edit Comment */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Motivo del cambio (obligatorio)
|
||
</label>
|
||
<textarea
|
||
value={editFormData.edit_comment}
|
||
onChange={(e) => setEditFormData({...editFormData, edit_comment: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
placeholder="Explica por qué estás haciendo este cambio..."
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex gap-2 pt-2">
|
||
<button
|
||
onClick={() => saveEdit(answer.id)}
|
||
disabled={!editFormData.edit_comment}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Guardar Cambios
|
||
</button>
|
||
<button
|
||
onClick={cancelEdit}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Modo Vista Normal
|
||
<>
|
||
{/* Answer Value */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">Respuesta:</span>
|
||
{question.type === 'pass_fail' ? (
|
||
getStatusBadge(answer.status)
|
||
) : (
|
||
<span className="font-medium">{getReadableAnswer(answer.answer_value, question.options)}</span>
|
||
)}
|
||
{answer.is_flagged && (
|
||
<span className="text-red-600 text-sm">🚩 Señalado</span>
|
||
)}
|
||
{/* Botón Editar (solo admin) */}
|
||
{user?.role === 'admin' && inspection.status === 'completed' && (
|
||
<button
|
||
onClick={() => startEditAnswer(answer)}
|
||
className="ml-2 px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Comment */}
|
||
{answer.comment && (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded p-2">
|
||
<span className="text-xs text-yellow-800 font-medium">Observación:</span>
|
||
<p className="text-sm text-yellow-900 mt-1">{answer.comment}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Analysis - SOLO VISIBLE PARA ADMIN */}
|
||
{user?.role === 'admin' && answer.ai_analysis && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded p-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-xs text-purple-800 font-semibold">🤖 Análisis de IA</span>
|
||
</div>
|
||
<div className="text-xs text-purple-900 space-y-1">
|
||
{Array.isArray(answer.ai_analysis) ? (
|
||
answer.ai_analysis.map((analysis, idx) => (
|
||
<div key={idx} className="border-b border-purple-200 last:border-0 pb-2">
|
||
<div className="font-medium">📸 Imagen {idx + 1}:</div>
|
||
{analysis.analysis && typeof analysis.analysis === 'object' ? (
|
||
<>
|
||
<div>Estado: <span className="font-semibold">{analysis.analysis.status?.toUpperCase()}</span></div>
|
||
{analysis.analysis.observations && <div>Observaciones: {analysis.analysis.observations}</div>}
|
||
{analysis.analysis.recommendation && <div>Recomendación: {analysis.analysis.recommendation}</div>}
|
||
{analysis.analysis.confidence && <div>Confianza: {(analysis.analysis.confidence * 100).toFixed(0)}%</div>}
|
||
</>
|
||
) : (
|
||
<div>{JSON.stringify(analysis.analysis)}</div>
|
||
)}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div>{JSON.stringify(answer.ai_analysis)}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Photos - NUEVO: miniaturas de media_files */}
|
||
{(answer.media_files && answer.media_files.length > 0) && (
|
||
<div className="flex gap-2 flex-wrap mt-2">
|
||
{answer.media_files.map((media, idx) => (
|
||
<img
|
||
key={idx}
|
||
src={media.file_path}
|
||
alt={`Foto ${idx + 1}`}
|
||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||
onClick={() => window.open(media.file_path, '_blank')}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Photos - compatibilidad legacy */}
|
||
{(answer.photos && answer.photos.length > 0) && (
|
||
<div className="flex gap-2 flex-wrap mt-2">
|
||
{answer.photos.map((photo, idx) => (
|
||
<img
|
||
key={idx}
|
||
src={photo}
|
||
alt={`Foto ${idx + 1}`}
|
||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||
onClick={() => window.open(photo, '_blank')}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Points */}
|
||
{question.points_value > 0 && (
|
||
<div className="text-sm">
|
||
<span className="text-gray-600">Puntos:</span>
|
||
<span className="ml-2 font-medium text-blue-600">
|
||
{answer.points_earned || 0}/{question.points_value}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!answer && (
|
||
<div className="mt-2 ml-10 text-sm text-gray-400 italic">
|
||
Sin respuesta
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Signatures */}
|
||
{inspectionDetail && inspectionDetail.mechanic_signature && (
|
||
<div className="mt-6 border-t pt-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Firma del Mecánico</h3>
|
||
<div className="bg-gray-50 rounded-lg p-4 inline-block">
|
||
<img
|
||
src={inspectionDetail.mechanic_signature}
|
||
alt="Firma Mecánico"
|
||
className="h-24 border border-gray-300 rounded"
|
||
/>
|
||
<p className="text-sm text-gray-600 mt-2">
|
||
Mecánico: {inspectionDetail.mechanic?.full_name || 'N/A'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<div className="flex gap-3">
|
||
{/* Botón Continuar Inspección - solo si está incompleta */}
|
||
{inspection.status !== 'completed' && onContinue && (
|
||
<button
|
||
onClick={() => {
|
||
// Pasar inspectionDetail que tiene el checklist completo
|
||
onContinue(inspectionDetail || inspection)
|
||
onClose()
|
||
}}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition flex items-center gap-2"
|
||
>
|
||
<span>▶️</span>
|
||
Continuar Inspección
|
||
</button>
|
||
)}
|
||
{user?.role === 'admin' && (
|
||
<button
|
||
onClick={loadAuditLog}
|
||
disabled={loadingAudit}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition flex items-center gap-2"
|
||
>
|
||
<span>📜</span>
|
||
{loadingAudit ? 'Cargando...' : 'Ver Historial de Cambios'}
|
||
</button>
|
||
)}
|
||
{/* Botón Exportar PDF - solo para admin y asesor */}
|
||
{(user?.role === 'admin' || user?.role === 'asesor') && (
|
||
<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 contentType = response.headers.get('content-type')
|
||
if (contentType && contentType.includes('application/pdf')) {
|
||
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 {
|
||
const data = await response.json()
|
||
if (data.pdf_url) {
|
||
const pdfRes = await fetch(data.pdf_url)
|
||
if (pdfRes.ok) {
|
||
const pdfBlob = await pdfRes.blob()
|
||
const pdfUrl = window.URL.createObjectURL(pdfBlob)
|
||
const a = document.createElement('a')
|
||
a.href = pdfUrl
|
||
a.download = `inspeccion_${inspection.id}_${inspection.vehicle_plate || 'sin-patente'}.pdf`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(pdfUrl)
|
||
} else {
|
||
alert('No se pudo descargar el PDF desde MinIO')
|
||
}
|
||
} else {
|
||
alert('No se encontró la URL del PDF')
|
||
}
|
||
}
|
||
} 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
|
||
onClick={async () => {
|
||
if (confirm('¿Deseas inactivar esta inspección?')) {
|
||
setIsInactivating(true)
|
||
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}/deactivate`, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
alert('Inspección inactivada correctamente')
|
||
onUpdate && onUpdate()
|
||
onClose()
|
||
} else {
|
||
alert('Error al inactivar la inspección')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al inactivar la inspección')
|
||
}
|
||
setIsInactivating(false)
|
||
}
|
||
}}
|
||
disabled={isInactivating}
|
||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50"
|
||
>
|
||
{isInactivating ? 'Inactivando...' : 'Inactivar'}
|
||
</button>
|
||
</>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal de Historial de Auditoría */}
|
||
{showAuditLog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||
<div className="bg-purple-600 text-white p-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">📜 Historial de Cambios</h2>
|
||
<p className="mt-1 opacity-90">Inspección #{inspection.id}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowAuditLog(false)}
|
||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{auditLogs.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-4xl mb-3">📝</div>
|
||
<p className="text-gray-600">No hay cambios registrados en esta inspección</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
Los cambios realizados por administradores aparecerán aquí
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{auditLogs.map((log) => (
|
||
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">
|
||
{log.action === 'created' && '➕'}
|
||
{log.action === 'updated' && '✏️'}
|
||
{log.action === 'deleted' && '🗑️'}
|
||
{log.action === 'status_changed' && '🔄'}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="font-semibold text-gray-900">
|
||
{log.user_name || `Usuario #${log.user_id}`}
|
||
</span>
|
||
<span className="text-gray-400">•</span>
|
||
<span className="text-sm text-gray-500">
|
||
{new Date(log.created_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-sm">
|
||
<span className="text-gray-600">Acción: </span>
|
||
<span className="font-medium capitalize">{log.action}</span>
|
||
<span className="text-gray-400 mx-2">en</span>
|
||
<span className="font-medium">{log.entity_type}</span>
|
||
{log.answer_id && (
|
||
<span className="text-gray-500"> (Respuesta #{log.answer_id})</span>
|
||
)}
|
||
</div>
|
||
|
||
{log.field_name && (
|
||
<div className="mt-2 bg-gray-50 rounded p-3">
|
||
<div className="text-xs font-semibold text-gray-600 mb-2">
|
||
Campo modificado: <span className="text-purple-600">{log.field_name}</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||
<div className="bg-red-50 border border-red-200 rounded px-2 py-1 text-red-800">
|
||
{log.old_value || '(vacío)'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||
<div className="bg-green-50 border border-green-200 rounded px-2 py-1 text-green-800">
|
||
{log.new_value || '(vacío)'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{log.comment && (
|
||
<div className="mt-2 bg-yellow-50 border border-yellow-200 rounded p-2">
|
||
<div className="text-xs font-semibold text-yellow-800 mb-1">Motivo:</div>
|
||
<div className="text-sm text-yellow-900">{log.comment}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={() => setShowAuditLog(false)}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InspectionsTab({ inspections, user, onUpdate, onContinue }) {
|
||
const [selectedInspection, setSelectedInspection] = useState(null)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState('all') // all, completed, incomplete
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
|
||
// Filtrar inspecciones
|
||
const filteredInspections = inspections.filter(inspection => {
|
||
const matchesSearch =
|
||
inspection.vehicle_plate?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
inspection.vehicle_brand?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
inspection.vehicle_model?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
inspection.order_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
inspection.or_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
inspection.id?.toString().includes(searchTerm)
|
||
|
||
const matchesStatus =
|
||
statusFilter === 'all' ||
|
||
(statusFilter === 'completed' && inspection.status === 'completed') ||
|
||
(statusFilter === 'incomplete' && inspection.status !== 'completed')
|
||
|
||
return matchesSearch && matchesStatus
|
||
})
|
||
|
||
// Calcular paginación
|
||
const totalPages = Math.ceil(filteredInspections.length / itemsPerPage)
|
||
const startIndex = (currentPage - 1) * itemsPerPage
|
||
const endIndex = startIndex + itemsPerPage
|
||
const paginatedInspections = filteredInspections.slice(startIndex, endIndex)
|
||
|
||
// Reset a página 1 cuando cambian los filtros
|
||
useEffect(() => {
|
||
setCurrentPage(1)
|
||
}, [searchTerm, statusFilter])
|
||
|
||
if (inspections.length === 0) {
|
||
return (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No hay inspecciones registradas</p>
|
||
<p className="text-sm text-gray-400 mt-2">Crea tu primera inspección</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* Buscador y Filtros */}
|
||
<div className="mb-6 space-y-4">
|
||
<div className="flex gap-4 flex-wrap">
|
||
{/* Buscador */}
|
||
<div className="flex-1 min-w-[300px]">
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar por placa, marca, modelo, Nº pedido, OR o ID..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
{/* Filtro de Estado */}
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="all">Todos los estados</option>
|
||
<option value="completed">Completadas</option>
|
||
<option value="incomplete">Incompletas</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Contador de resultados */}
|
||
<div className="text-sm text-gray-600">
|
||
Mostrando {startIndex + 1}-{Math.min(endIndex, filteredInspections.length)} de {filteredInspections.length} inspecciones
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lista de Inspecciones */}
|
||
{filteredInspections.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No se encontraron inspecciones con los filtros aplicados</p>
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm('')
|
||
setStatusFilter('all')
|
||
}}
|
||
className="mt-4 text-blue-600 hover:text-blue-700 underline"
|
||
>
|
||
Limpiar filtros
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{paginatedInspections.map((inspection) => (
|
||
<div key={inspection.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{inspection.vehicle_plate}
|
||
</h3>
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||
inspection.status === 'completed'
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-yellow-100 text-yellow-800'
|
||
}`}>
|
||
{inspection.status === 'completed' ? 'Completada' : 'Incompleta'}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
{inspection.vehicle_brand} {inspection.vehicle_model} - {inspection.vehicle_km} km
|
||
</p>
|
||
<div className="flex gap-4 mt-3 text-sm">
|
||
<span className="text-gray-500">
|
||
OR: <strong>{inspection.or_number || 'N/A'}</strong>
|
||
</span>
|
||
<span className="text-gray-500">
|
||
Score: <strong>{inspection.score}/{inspection.max_score}</strong> ({inspection.percentage.toFixed(1)}%)
|
||
</span>
|
||
{inspection.flagged_items_count > 0 && (
|
||
<span className="text-red-600">
|
||
⚠️ <strong>{inspection.flagged_items_count}</strong> elementos señalados
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedInspection(inspection)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Ver Detalle
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Controles de paginación */}
|
||
{filteredInspections.length > itemsPerPage && (
|
||
<div className="flex items-center justify-center gap-2 mt-6">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
← Anterior
|
||
</button>
|
||
|
||
<div className="flex gap-1">
|
||
{[...Array(totalPages)].map((_, index) => {
|
||
const page = index + 1
|
||
// Mostrar solo páginas cercanas a la actual
|
||
if (
|
||
page === 1 ||
|
||
page === totalPages ||
|
||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||
) {
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-3 py-2 rounded-lg ${
|
||
currentPage === page
|
||
? 'bg-blue-600 text-white'
|
||
: 'border border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
} else if (page === currentPage - 2 || page === currentPage + 2) {
|
||
return <span key={page} className="px-2 py-2">...</span>
|
||
}
|
||
return null
|
||
})}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Siguiente →
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal de Detalle */}
|
||
{selectedInspection && (
|
||
<InspectionDetailModal
|
||
inspection={selectedInspection}
|
||
user={user}
|
||
onClose={() => setSelectedInspection(null)}
|
||
onUpdate={onUpdate}
|
||
onContinue={onContinue}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function InspectionModal({ checklist, existingInspection, user, onClose, onComplete }) {
|
||
const [step, setStep] = useState(1) // 1: Vehicle Info, 2: Questions, 3: Signatures
|
||
const [loading, setLoading] = useState(false)
|
||
const [questions, setQuestions] = useState([])
|
||
const [inspectionId, setInspectionId] = useState(existingInspection?.id || null)
|
||
|
||
// Form data for vehicle and client
|
||
const [vehicleData, setVehicleData] = useState({
|
||
vehicle_plate: existingInspection?.vehicle_plate || '',
|
||
vehicle_brand: existingInspection?.vehicle_brand || '',
|
||
vehicle_model: existingInspection?.vehicle_model || '',
|
||
vehicle_km: existingInspection?.vehicle_km || '',
|
||
order_number: existingInspection?.order_number || '',
|
||
or_number: existingInspection?.or_number || ''
|
||
})
|
||
|
||
// Answers data
|
||
const [answers, setAnswers] = useState({})
|
||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||
const [aiAnalyzing, setAiAnalyzing] = useState(false)
|
||
|
||
// AI Assistant Chat
|
||
const [showAIChat, setShowAIChat] = useState(false)
|
||
const [aiChatMessages, setAiChatMessages] = useState([])
|
||
const [aiChatLoading, setAiChatLoading] = useState(false)
|
||
|
||
// Signature canvas
|
||
const mechanicSigRef = useRef(null)
|
||
|
||
// Load questions when modal opens
|
||
useEffect(() => {
|
||
const loadQuestions = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log('Loading questions for checklist:', checklist.id)
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
console.log('Questions response status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const checklistData = await response.json()
|
||
const questionsData = checklistData.questions || []
|
||
console.log('Questions loaded:', questionsData.length, 'questions')
|
||
setQuestions(questionsData)
|
||
|
||
// Initialize answers object
|
||
const initialAnswers = {}
|
||
|
||
// Si hay inspección existente, cargar sus respuestas
|
||
if (existingInspection?.id) {
|
||
console.log('Loading existing inspection:', existingInspection.id)
|
||
const inspResponse = await fetch(`${API_URL}/api/inspections/${existingInspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (inspResponse.ok) {
|
||
const inspData = await inspResponse.json()
|
||
console.log('Existing inspection loaded:', inspData)
|
||
|
||
// Cargar respuestas existentes
|
||
questionsData.forEach(q => {
|
||
const existingAnswer = inspData.answers?.find(a => a.question_id === q.id)
|
||
if (existingAnswer) {
|
||
initialAnswers[q.id] = {
|
||
value: existingAnswer.answer_value || '',
|
||
observations: existingAnswer.comment || '',
|
||
photos: existingAnswer.media_files?.map(m => m.file_path) || []
|
||
}
|
||
} else {
|
||
initialAnswers[q.id] = {
|
||
value: '',
|
||
observations: '',
|
||
photos: []
|
||
}
|
||
}
|
||
})
|
||
|
||
// Si ya tiene respuestas, ir al paso 2
|
||
if (inspData.answers?.length > 0) {
|
||
setStep(2)
|
||
}
|
||
}
|
||
} else {
|
||
// Nueva inspección - inicializar vacío
|
||
questionsData.forEach(q => {
|
||
initialAnswers[q.id] = {
|
||
value: '', // No default value - user must choose
|
||
observations: '',
|
||
photos: []
|
||
}
|
||
})
|
||
}
|
||
|
||
console.log('Initial answers:', initialAnswers)
|
||
setAnswers(initialAnswers)
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.error('Error loading questions:', errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading questions:', error)
|
||
}
|
||
}
|
||
|
||
loadQuestions()
|
||
}, [checklist.id, existingInspection])
|
||
|
||
// Step 1: Create inspection with vehicle data
|
||
const handleVehicleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Si ya existe una inspección, solo pasar al siguiente paso
|
||
if (existingInspection?.id) {
|
||
console.log('Continuing existing inspection:', existingInspection.id)
|
||
if (questions.length > 0) {
|
||
setStep(2)
|
||
} else {
|
||
alert('Error: El checklist no tiene preguntas configuradas')
|
||
}
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
console.log('Creating inspection with data:', vehicleData)
|
||
|
||
// Prepare data for API
|
||
const inspectionData = {
|
||
checklist_id: checklist.id,
|
||
vehicle_plate: vehicleData.vehicle_plate,
|
||
vehicle_brand: vehicleData.vehicle_brand || null,
|
||
vehicle_model: vehicleData.vehicle_model || null,
|
||
vehicle_km: vehicleData.vehicle_km ? parseInt(vehicleData.vehicle_km) : null,
|
||
order_number: vehicleData.order_number || null,
|
||
or_number: vehicleData.or_number || null
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/inspections`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(inspectionData)
|
||
})
|
||
|
||
console.log('Response status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const inspection = await response.json()
|
||
console.log('Inspection created:', inspection)
|
||
setInspectionId(inspection.id)
|
||
|
||
// Only move to step 2 if questions are loaded
|
||
if (questions.length > 0) {
|
||
setStep(2)
|
||
} else {
|
||
alert('Error: El checklist no tiene preguntas configuradas')
|
||
}
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.error('Error response:', errorText)
|
||
alert('Error al crear la inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear la inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Step 2: Auto-save answer when changed (non-blocking)
|
||
const saveAnswer = async (questionId) => {
|
||
if (!inspectionId) return
|
||
|
||
const question = questions.find(q => q.id === questionId)
|
||
const answer = answers[questionId]
|
||
|
||
// Don't save if no value AND no observations AND no photos
|
||
if (!answer?.value && !answer?.observations && (!answer?.photos || answer.photos.length === 0)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Determine status based on answer value and question config
|
||
let status = 'ok'
|
||
const config = question.options || {}
|
||
const questionType = config.type || question.type
|
||
|
||
if (answer?.value) {
|
||
if (questionType === 'boolean' && config.choices) {
|
||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||
status = selectedChoice?.status || 'ok'
|
||
} else if (questionType === 'single_choice' && config.choices) {
|
||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||
status = selectedChoice?.status || 'ok'
|
||
} else if (questionType === 'pass_fail') {
|
||
// Compatibilidad hacia atrás
|
||
status = answer.value === 'pass' ? 'ok' : 'critical'
|
||
} else if (questionType === 'good_bad') {
|
||
// Compatibilidad hacia atrás
|
||
if (answer.value === 'good') status = 'ok'
|
||
else if (answer.value === 'regular') status = 'warning'
|
||
else if (answer.value === 'bad') status = 'critical'
|
||
}
|
||
}
|
||
|
||
// Submit answer
|
||
const answerData = {
|
||
inspection_id: inspectionId,
|
||
question_id: question.id,
|
||
answer_value: answer.value || null,
|
||
status: status,
|
||
comment: answer.observations || null,
|
||
ai_analysis: answer.aiAnalysis || null,
|
||
is_flagged: status === 'critical'
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/answers`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(answerData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const savedAnswer = await response.json()
|
||
|
||
// Upload photos if any
|
||
if (answer.photos.length > 0) {
|
||
for (const photoFile of answer.photos) {
|
||
const formData = new FormData()
|
||
formData.append('file', photoFile)
|
||
|
||
await fetch(`${API_URL}/api/answers/${savedAnswer.id}/upload`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
}
|
||
}
|
||
|
||
// Mark as saved
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: { ...prev[questionId], saved: true }
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving answer:', error)
|
||
}
|
||
}
|
||
|
||
// Navigate between questions freely
|
||
const goToQuestion = (index) => {
|
||
setCurrentQuestionIndex(index)
|
||
}
|
||
|
||
// Validate all questions answered before completing
|
||
const validateAllAnswered = () => {
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const unanswered = visibleQuestions.filter(q => {
|
||
// Para preguntas tipo photo_only, solo validar que tenga fotos
|
||
if (q.options?.type === 'photo_only') {
|
||
return !answers[q.id]?.photos?.length
|
||
}
|
||
// Para otros tipos, validar que tenga respuesta
|
||
return !answers[q.id]?.value
|
||
})
|
||
return unanswered
|
||
}
|
||
|
||
// Get visible questions based on conditional logic
|
||
const getVisibleQuestions = () => {
|
||
return questions.filter(q => {
|
||
// If no parent, always visible
|
||
if (!q.parent_question_id) return true
|
||
|
||
// Check parent answer
|
||
const parentAnswer = answers[q.parent_question_id]
|
||
if (!parentAnswer) return false
|
||
|
||
// Show if parent answer matches trigger
|
||
return parentAnswer.value === q.show_if_answer
|
||
})
|
||
}
|
||
|
||
// Move to signatures step
|
||
const proceedToSignatures = () => {
|
||
const unanswered = validateAllAnswered()
|
||
if (unanswered.length > 0) {
|
||
alert(`⚠️ Faltan responder ${unanswered.length} pregunta(s). Por favor completa todas las preguntas antes de continuar.`)
|
||
// Go to first unanswered
|
||
const firstIndex = questions.findIndex(q => q.id === unanswered[0].id)
|
||
if (firstIndex >= 0) setCurrentQuestionIndex(firstIndex)
|
||
return
|
||
}
|
||
setStep(3)
|
||
}
|
||
|
||
// Step 3: Submit signatures and complete
|
||
const handleComplete = async () => {
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Get mechanic signature as base64
|
||
const mechanicSig = mechanicSigRef.current ? mechanicSigRef.current.toDataURL() : null
|
||
|
||
// Update inspection with signature
|
||
const updateResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
signature_data: mechanicSig
|
||
})
|
||
})
|
||
|
||
console.log('Update inspection response:', updateResponse.status)
|
||
|
||
// Complete inspection
|
||
const completeResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}/complete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
console.log('Complete inspection response:', completeResponse.status)
|
||
|
||
if (completeResponse.ok) {
|
||
const completedInspection = await completeResponse.json()
|
||
console.log('Inspection completed:', completedInspection)
|
||
alert(`✅ Inspección completada!\n\nPuntuación: ${completedInspection.score}/${completedInspection.max_score} (${completedInspection.percentage.toFixed(1)}%)\nElementos señalados: ${completedInspection.flagged_items_count}`)
|
||
onComplete()
|
||
onClose()
|
||
} else {
|
||
const errorText = await completeResponse.text()
|
||
console.error('Error completing inspection:', errorText)
|
||
alert('Error al completar inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al completar inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handlePhotoChange = async (questionId, files) => {
|
||
const question = questions.find(q => q.id === questionId)
|
||
let filesArray = Array.from(files)
|
||
|
||
// Get existing photos
|
||
const existingPhotos = answers[questionId]?.photos || []
|
||
|
||
// Combine existing and new photos
|
||
const allPhotos = [...existingPhotos, ...filesArray]
|
||
|
||
// Validar límite de fotos
|
||
if (question.max_photos && allPhotos.length > question.max_photos) {
|
||
alert(`⚠️ Solo puedes subir hasta ${question.max_photos} foto${question.max_photos > 1 ? 's' : ''} para esta pregunta`)
|
||
return
|
||
}
|
||
|
||
// Update photos immediately (do NOT auto-analyze)
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: allPhotos
|
||
}
|
||
}))
|
||
}
|
||
|
||
const handleRemovePhoto = (questionId, photoIndex) => {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: prev[questionId].photos.filter((_, index) => index !== photoIndex)
|
||
}
|
||
}))
|
||
}
|
||
|
||
const handleAnalyzePhotos = async (questionId) => {
|
||
const photos = answers[questionId]?.photos || []
|
||
if (photos.length === 0) {
|
||
alert('Primero debes subir al menos una foto')
|
||
return
|
||
}
|
||
|
||
await analyzePhotosWithAI(questionId, photos)
|
||
}
|
||
|
||
const analyzePhotosWithAI = async (questionId, files) => {
|
||
const question = questions.find(q => q.id === questionId)
|
||
if (!question) return
|
||
|
||
console.log('🔍 Iniciando análisis IA para pregunta:', questionId)
|
||
console.log('📸 Archivos a analizar:', files.length)
|
||
console.log('🎯 Modo IA del checklist:', checklist.ai_mode)
|
||
|
||
setAiAnalyzing(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log(`🤖 Analizando ${files.length} foto(s) con IA para pregunta: ${question.text}`)
|
||
|
||
// Use custom AI prompt if available
|
||
if (question.ai_prompt) {
|
||
console.log(`📝 Usando prompt personalizado: ${question.ai_prompt.substring(0, 50)}...`)
|
||
}
|
||
|
||
// Analyze each photo sequentially
|
||
const analyses = []
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
console.log(`📸 Analizando foto ${i + 1} de ${files.length}: ${file.name}`)
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('question_id', question.id.toString())
|
||
|
||
console.log('📤 DATOS ENVIADOS AL BACKEND:')
|
||
console.log(' - question_id:', question.id)
|
||
console.log(' - question.text:', question.text)
|
||
console.log(' - question.ai_prompt:', question.ai_prompt || 'NO TIENE')
|
||
console.log(' - imagen:', i + 1, 'de', files.length)
|
||
|
||
// Include inspection_id for vehicle context
|
||
if (inspectionId) {
|
||
formData.append('inspection_id', inspectionId.toString())
|
||
console.log(' - inspection_id:', inspectionId)
|
||
} else {
|
||
console.log(' - inspection_id: NO ENVIADO')
|
||
}
|
||
|
||
// Include custom prompt if available
|
||
if (question.ai_prompt) {
|
||
formData.append('custom_prompt', question.ai_prompt)
|
||
console.log(' - custom_prompt ENVIADO:', question.ai_prompt)
|
||
} else {
|
||
console.log(' - custom_prompt: NO ENVIADO (pregunta no tiene ai_prompt)')
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/analyze-image`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
|
||
console.log('📥 RESPUESTA DEL BACKEND:')
|
||
console.log(' - Status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const result = await response.json()
|
||
console.log(' - Result completo:', JSON.stringify(result, null, 2))
|
||
|
||
// Check if AI analysis was successful
|
||
if (result.success && result.analysis) {
|
||
analyses.push({ ...result, imageIndex: i + 1, fileName: file.name })
|
||
console.log('✅ Análisis IA exitoso para imagen', i + 1)
|
||
console.log(' - Provider:', result.provider)
|
||
console.log(' - Model:', result.model)
|
||
console.log(' - Status:', result.analysis.status)
|
||
console.log(' - Observations:', result.analysis.observations)
|
||
} else {
|
||
console.warn('⚠️ Error en análisis IA:', result.error || result.message)
|
||
// Show user-friendly error
|
||
if (result.error && result.error.includes('No AI configuration')) {
|
||
alert('⚙️ Por favor configura tu API key en Configuración primero.')
|
||
break // Stop analyzing if no API key
|
||
}
|
||
}
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.warn('⚠️ Error HTTP en análisis IA:', response.status, errorText)
|
||
}
|
||
}
|
||
|
||
// Siempre guardar que se procesaron los documentos, aunque no haya análisis IA
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: files,
|
||
aiAnalysis: analyses.length > 0 ? analyses : undefined, // Guardar análisis solo si hay
|
||
documentsLoaded: true // Marcar que se procesaron los documentos
|
||
}
|
||
}))
|
||
|
||
if (analyses.length > 0) {
|
||
console.log(`✅ Análisis IA guardado (${analyses.length} análisis)`)
|
||
console.log(`📝 Las observaciones quedan para que el mecánico las escriba manualmente`)
|
||
|
||
// Verificar si alguna imagen no corresponde al contexto
|
||
const invalidImages = []
|
||
analyses.forEach((analysis, idx) => {
|
||
if (analysis.analysis) {
|
||
// Verificar si la IA indica que la imagen no corresponde
|
||
const obs = analysis.analysis.observations?.toLowerCase() || ''
|
||
const isInvalid =
|
||
obs.includes('no corresponde') ||
|
||
obs.includes('no coincide') ||
|
||
obs.includes('imagen incorrecta') ||
|
||
obs.includes('no es relevante') ||
|
||
obs.includes('no relacionad') ||
|
||
analysis.analysis.context_match === false
|
||
|
||
if (isInvalid) {
|
||
invalidImages.push({
|
||
index: idx + 1,
|
||
fileName: analysis.fileName,
|
||
reason: analysis.analysis.observations
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
// Mostrar advertencia si hay imágenes que no corresponden
|
||
if (invalidImages.length > 0) {
|
||
let warningMsg = '⚠️ ATENCIÓN: Se detectaron imágenes que podrían NO corresponder al contexto:\n\n'
|
||
invalidImages.forEach(img => {
|
||
warningMsg += `📸 Imagen ${img.index}: ${img.reason}\n\n`
|
||
})
|
||
warningMsg += '¿Deseas reemplazar estas imágenes?\n\n'
|
||
warningMsg += 'Presiona OK para eliminar las imágenes incorrectas y cargar nuevas.\n'
|
||
warningMsg += 'Presiona Cancelar si estás seguro de que las imágenes son correctas.'
|
||
|
||
if (confirm(warningMsg)) {
|
||
// Eliminar las imágenes que no corresponden
|
||
const validPhotos = files.filter((_, idx) => !invalidImages.some(inv => inv.index === idx + 1))
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: validPhotos,
|
||
aiAnalysis: undefined,
|
||
documentsLoaded: false // Resetear para que vuelva a cargar
|
||
}
|
||
}))
|
||
alert('📸 Por favor carga nuevas imágenes que correspondan a: ' + question.text)
|
||
return // No mostrar el popup de éxito
|
||
}
|
||
}
|
||
} else {
|
||
console.log('ℹ️ No se generaron análisis IA, pero documentos procesados')
|
||
}
|
||
|
||
// Mostrar popup de confirmación
|
||
alert('✅ Documentos cargados correctamente')
|
||
} catch (error) {
|
||
console.error('❌ Error al analizar fotos con IA:', error)
|
||
|
||
// Aun con error, marcar documentos como cargados
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: files,
|
||
documentsLoaded: true
|
||
}
|
||
}))
|
||
|
||
alert('✅ Documentos cargados (sin análisis IA)')
|
||
} finally {
|
||
setAiAnalyzing(false)
|
||
}
|
||
}
|
||
|
||
// Get visible questions based on conditional logic
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const currentQuestion = visibleQuestions[currentQuestionIndex]
|
||
|
||
// Barra de navegación de preguntas
|
||
// Paginación para la barra de preguntas
|
||
const QUESTIONS_PER_PAGE = 8;
|
||
const [questionPage, setQuestionPage] = useState(0);
|
||
const totalPages = Math.ceil(visibleQuestions.length / QUESTIONS_PER_PAGE);
|
||
const startIdx = questionPage * QUESTIONS_PER_PAGE;
|
||
const endIdx = startIdx + QUESTIONS_PER_PAGE;
|
||
const visibleBlock = visibleQuestions.slice(startIdx, endIdx);
|
||
|
||
const QuestionNavigator = () => (
|
||
<div className="flex items-center gap-1 sm:gap-2 mb-4 sm:mb-6 justify-center overflow-x-auto pb-2">
|
||
{/* Flecha izquierda */}
|
||
<button
|
||
onClick={() => setQuestionPage((p) => Math.max(0, p - 1))}
|
||
disabled={questionPage === 0}
|
||
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full border flex items-center justify-center text-lg sm:text-xl font-bold transition-all select-none bg-gray-700 text-white border-gray-900 shadow-lg disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0`}
|
||
title="Anterior"
|
||
>
|
||
←
|
||
</button>
|
||
{/* Números de preguntas */}
|
||
{visibleBlock.map((q, idx) => {
|
||
const globalIdx = startIdx + idx;
|
||
const answered = answers[q.id]?.value;
|
||
let base = 'w-8 h-8 sm:w-10 sm:h-10 rounded-full border shadow-lg flex items-center justify-center text-sm sm:text-lg font-bold transition-all select-none flex-shrink-0';
|
||
let style = '';
|
||
if (globalIdx === currentQuestionIndex) {
|
||
style = 'bg-blue-900 text-white border-blue-900 scale-110';
|
||
} else if (answered) {
|
||
style = 'bg-green-700 text-white border-green-800';
|
||
} else {
|
||
style = 'bg-gray-700 text-white border-gray-900';
|
||
}
|
||
return (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => goToQuestion(globalIdx)}
|
||
className={`${base} ${style} hover:bg-blue-700 hover:border-blue-700`}
|
||
title={`Pregunta ${globalIdx + 1}${answered ? ' (respondida)' : ''}`}
|
||
>
|
||
{globalIdx + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
{/* Flecha derecha */}
|
||
<button
|
||
onClick={() => setQuestionPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={questionPage >= totalPages - 1}
|
||
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full border flex items-center justify-center text-lg sm:text-xl font-bold transition-all select-none bg-gray-700 text-white border-gray-900 shadow-lg disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0`}
|
||
title="Siguiente"
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||
<div className="bg-white rounded-lg w-full max-w-4xl max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||
<div className="p-3 sm:p-4 lg:p-6">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-start gap-2 mb-3 sm:mb-4">
|
||
<h2 className="text-lg sm:text-xl lg:text-2xl font-bold text-gray-900 leading-tight">
|
||
Nueva Inspección: {checklist.name}
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 text-2xl sm:text-3xl flex-shrink-0"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* AI Mode Banner */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className={`mb-3 sm:mb-4 p-2 sm:p-3 rounded-lg border ${
|
||
checklist.ai_mode === 'full'
|
||
? 'bg-purple-50 border-purple-200'
|
||
: 'bg-blue-50 border-blue-200'
|
||
}`}>
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-lg sm:text-xl flex-shrink-0">🤖</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className={`text-xs sm:text-sm font-medium ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-900' : 'text-blue-900'
|
||
}`}>
|
||
{checklist.ai_mode === 'full' ? 'Modo AUTOCOMPLETADO activado' : 'Modo ASISTIDO activado'}
|
||
</p>
|
||
<p className={`text-xs ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-700' : 'text-blue-700'
|
||
} mt-1`}>
|
||
{checklist.ai_mode === 'full'
|
||
? 'El sistema completará automáticamente las respuestas al cargar documentos. Revisa y ajusta si es necesario.'
|
||
: 'El sistema sugerirá observaciones al cargar documentos.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress indicator */}
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
Paso {step} de 3
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{step === 1 && 'Datos del Vehículo'}
|
||
{step === 2 && (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${answered}/${visible.length} preguntas respondidas`
|
||
})()}
|
||
{step === 3 && 'Firmas'}
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||
style={{
|
||
width: step === 1 ? '33%' : step === 2 ? (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${33 + (answered / visible.length) * 33}%`
|
||
})() : '100%'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 1: Vehicle Data */}
|
||
{step === 1 && (
|
||
<form onSubmit={handleVehicleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Placa del Vehículo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_plate}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_plate: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ABC-123"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Marca *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_brand}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_brand: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Toyota"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Modelo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_model}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_model: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Corolla"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Kilometraje
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={vehicleData.vehicle_km}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_km: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="50000"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Número de OR
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.or_number}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, or_number: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="OR-001"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Código de Operario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={user.employee_code || 'No asignado'}
|
||
readOnly
|
||
className="w-full px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed"
|
||
title="Este código se asigna automáticamente del perfil del usuario"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nº de Pedido
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.order_number}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, order_number: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="PED-12345"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
>
|
||
{loading ? 'Creando...' : 'Continuar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Step 2: Questions */}
|
||
{step === 2 && questions.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Cargando preguntas...</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && questions.length > 0 && !currentQuestion && (
|
||
<div className="text-center py-12">
|
||
<p className="text-red-500">No hay más preguntas disponibles</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && currentQuestion && (
|
||
<div className="space-y-3 sm:space-y-4 lg:space-y-6">
|
||
{/* Barra de navegación de preguntas */}
|
||
<QuestionNavigator />
|
||
<div className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
||
<div className="text-xs sm:text-sm text-gray-600 mb-1">
|
||
Sección: <strong>{currentQuestion.section}</strong>
|
||
</div>
|
||
<h3 className="text-base sm:text-lg font-semibold text-gray-900 leading-tight">
|
||
{currentQuestion.text}
|
||
</h3>
|
||
{currentQuestion.points > 0 && (
|
||
<div className="text-xs sm:text-sm text-blue-600 mt-1">
|
||
Puntos: {currentQuestion.points}
|
||
</div>
|
||
)}
|
||
{console.log('Current question:', currentQuestion.id, 'Type:', currentQuestion.type, 'Answer:', answers[currentQuestion.id])}
|
||
</div>
|
||
|
||
<div className="space-y-3 sm:space-y-4">
|
||
{/* Answer input based on type - NO mostrar para photo_only */}
|
||
{currentQuestion.options?.type !== 'photo_only' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Respuesta *
|
||
</label>
|
||
|
||
<QuestionAnswerInput
|
||
question={currentQuestion}
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(newValue) => {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||
}))
|
||
}}
|
||
onSave={() => setTimeout(() => saveAnswer(currentQuestion.id), 500)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Observations - NO mostrar para photo_only */}
|
||
{currentQuestion.options?.type !== 'photo_only' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Observaciones (opcional)
|
||
</label>
|
||
<textarea
|
||
value={answers[currentQuestion.id]?.observations || ''}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: {
|
||
...(prev[currentQuestion.id] || { value: '', observations: '', photos: [] }),
|
||
observations: e.target.value
|
||
}
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
placeholder="Notas adicionales..."
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Photos */}
|
||
{currentQuestion.allow_photos && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Fotografías *
|
||
{currentQuestion.max_photos && (
|
||
<span className="ml-2 text-xs text-gray-600">
|
||
(máximo {currentQuestion.max_photos} foto{currentQuestion.max_photos > 1 ? 's' : ''})
|
||
</span>
|
||
)}
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||
<span className="ml-2 text-xs text-blue-600">
|
||
📋 Procesamiento automático disponible
|
||
</span>
|
||
)}
|
||
</label>
|
||
|
||
<input
|
||
key={`photo-input-${currentQuestion.id}`}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple={currentQuestion.max_photos > 1}
|
||
onChange={(e) => {
|
||
handlePhotoChange(currentQuestion.id, e.target.files)
|
||
e.target.value = '' // Reset input después de procesar
|
||
}}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
disabled={aiAnalyzing}
|
||
required={!answers[currentQuestion.id]?.photos?.length}
|
||
/>
|
||
|
||
{/* Photo Previews */}
|
||
{answers[currentQuestion.id]?.photos?.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="text-sm font-medium text-gray-700">
|
||
{answers[currentQuestion.id].photos.length} foto(s) cargada(s):
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{answers[currentQuestion.id].photos.map((photo, index) => (
|
||
<div key={index} className="relative group">
|
||
<img
|
||
src={URL.createObjectURL(photo)}
|
||
alt={`Foto ${index + 1}`}
|
||
className="w-full h-24 object-cover rounded-lg border border-gray-300"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemovePhoto(currentQuestion.id, index)}
|
||
className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Eliminar foto"
|
||
>
|
||
✕
|
||
</button>
|
||
<div className="text-xs text-center text-gray-600 mt-1">
|
||
Foto {index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Analyze Button */}
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleAnalyzePhotos(currentQuestion.id)}
|
||
disabled={aiAnalyzing}
|
||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition flex items-center justify-center gap-2"
|
||
>
|
||
{aiAnalyzing ? (
|
||
<>
|
||
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
|
||
<span>Procesando...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>📁</span>
|
||
<span>Cargar Documentos</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{/* Botón para abrir chat IA (si es tipo ai_assistant) */}
|
||
{currentQuestion.options?.type === 'ai_assistant' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowAIChat(true)
|
||
// Cargar historial si existe
|
||
if (answers[currentQuestion.id]?.chatHistory) {
|
||
setAiChatMessages(answers[currentQuestion.id].chatHistory)
|
||
}
|
||
}}
|
||
className="w-full mt-3 px-4 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 transition flex items-center justify-center gap-2 font-semibold shadow-lg"
|
||
>
|
||
<span>💬</span>
|
||
<span>Consultar Asistente IA</span>
|
||
{answers[currentQuestion.id]?.chatHistory?.length > 0 && (
|
||
<span className="ml-1 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||
{answers[currentQuestion.id].chatHistory.length} mensajes
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{aiAnalyzing && (
|
||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||
<div className="flex items-center gap-2 text-blue-700">
|
||
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||
<span className="text-sm font-medium">Procesando {answers[currentQuestion.id]?.photos?.length || 0} documento(s)...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-2 sm:gap-3 pt-3 sm:pt-4 border-t">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (currentQuestionIndex > 0) {
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex - 1)
|
||
}
|
||
}}
|
||
disabled={currentQuestionIndex === 0}
|
||
className="px-3 sm:px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base"
|
||
>
|
||
<span className="hidden sm:inline">← Anterior</span>
|
||
<span className="sm:hidden">←</span>
|
||
</button>
|
||
|
||
{currentQuestionIndex < getVisibleQuestions().length - 1 ? (
|
||
<button
|
||
onClick={() => {
|
||
// Validar que se hayan subido fotos si son obligatorias
|
||
if (currentQuestion.allow_photos && (!answers[currentQuestion.id]?.photos || answers[currentQuestion.id].photos.length === 0)) {
|
||
alert('⚠️ Debes subir al menos una fotografía para esta pregunta')
|
||
return
|
||
}
|
||
// Validar que se hayan cargado documentos si hay fotos y está en modo IA
|
||
if ((checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') &&
|
||
answers[currentQuestion.id]?.photos?.length > 0 &&
|
||
!answers[currentQuestion.id]?.documentsLoaded) {
|
||
alert('⚠️ Debes presionar "Cargar Documentos" antes de continuar')
|
||
return
|
||
}
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex + 1)
|
||
}}
|
||
className="flex-1 px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-sm sm:text-base"
|
||
>
|
||
<span className="hidden sm:inline">Siguiente →</span>
|
||
<span className="sm:hidden">→</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => {
|
||
// Validar que se hayan subido fotos si son obligatorias
|
||
if (currentQuestion.allow_photos && (!answers[currentQuestion.id]?.photos || answers[currentQuestion.id].photos.length === 0)) {
|
||
alert('⚠️ Debes subir al menos una fotografía para esta pregunta')
|
||
return
|
||
}
|
||
// Validar que se hayan cargado documentos si hay fotos y está en modo IA
|
||
if ((checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') &&
|
||
answers[currentQuestion.id]?.photos?.length > 0 &&
|
||
!answers[currentQuestion.id]?.documentsLoaded) {
|
||
alert('⚠️ Debes presionar "Cargar Documentos" antes de continuar')
|
||
return
|
||
}
|
||
saveAnswer(currentQuestion.id)
|
||
proceedToSignatures()
|
||
}}
|
||
className="flex-1 px-3 sm:px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition text-sm sm:text-base"
|
||
>
|
||
<span className="hidden sm:inline">Completar y Firmar →</span>
|
||
<span className="sm:hidden">✓ Firmar</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Answer status indicator */}
|
||
{answers[currentQuestion.id]?.value && (
|
||
<div className="text-sm text-green-600 mt-2 flex items-center gap-1">
|
||
<span>✓</span>
|
||
<span>Respuesta guardada</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Signatures */}
|
||
{step === 3 && (
|
||
<div className="space-y-6">
|
||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||
<p className="text-green-800">
|
||
✓ Todas las preguntas han sido respondidas. Por favor, agregue su firma para completar la inspección.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Firma del Mecánico
|
||
</label>
|
||
<div className="border-2 border-gray-300 rounded-lg">
|
||
<SignatureCanvas
|
||
ref={mechanicSigRef}
|
||
canvasProps={{
|
||
className: 'w-full h-48 cursor-crosshair'
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => mechanicSigRef.current?.clear()}
|
||
className="mt-2 text-sm text-red-600 hover:text-red-700"
|
||
>
|
||
Limpiar Firma
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setStep(2)}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
← Volver a Preguntas
|
||
</button>
|
||
<button
|
||
onClick={handleComplete}
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||
>
|
||
{loading ? 'Finalizando...' : '✓ Finalizar Inspección'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal de Chat IA Asistente */}
|
||
{showAIChat && currentQuestion && (
|
||
<AIAssistantChatModal
|
||
question={currentQuestion}
|
||
inspection={{ id: inspectionId, ...vehicleData }}
|
||
allAnswers={answers}
|
||
messages={aiChatMessages}
|
||
setMessages={setAiChatMessages}
|
||
loading={aiChatLoading}
|
||
setLoading={setAiChatLoading}
|
||
onClose={() => {
|
||
setShowAIChat(false)
|
||
// Guardar historial del chat en la respuesta
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: {
|
||
...(prev[currentQuestion.id] || {}),
|
||
chatHistory: aiChatMessages,
|
||
value: 'chat_completed' // Marcar como respondida
|
||
}
|
||
}))
|
||
saveAnswer(currentQuestion.id)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente Modal de Chat IA Asistente
|
||
function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) {
|
||
const [inputMessage, setInputMessage] = useState('')
|
||
const chatEndRef = useRef(null)
|
||
const config = question.options || {}
|
||
|
||
// Auto-scroll al final
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}, [messages])
|
||
|
||
// Enviar mensaje al asistente
|
||
const sendMessage = async () => {
|
||
if (!inputMessage.trim() || loading) return
|
||
|
||
const userMessage = { role: 'user', content: inputMessage, timestamp: new Date().toISOString() }
|
||
setMessages(prev => [...prev, userMessage])
|
||
setInputMessage('')
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Recopilar fotos de preguntas anteriores según configuración
|
||
const contextPhotos = []
|
||
const contextQuestionIds = config.context_questions
|
||
? config.context_questions.split(',').map(id => parseInt(id.trim()))
|
||
: Object.keys(allAnswers).map(id => parseInt(id))
|
||
|
||
// Filtrar solo preguntas anteriores a la actual
|
||
const previousQuestionIds = contextQuestionIds.filter(id => id < question.id)
|
||
|
||
previousQuestionIds.forEach(qId => {
|
||
const answer = allAnswers[qId]
|
||
if (answer?.photos && answer.photos.length > 0) {
|
||
answer.photos.forEach(photoUrl => {
|
||
contextPhotos.push({
|
||
questionId: qId,
|
||
url: photoUrl,
|
||
aiAnalysis: answer.aiAnalysis
|
||
})
|
||
})
|
||
}
|
||
})
|
||
|
||
// Preparar el payload
|
||
const payload = {
|
||
question_id: question.id,
|
||
inspection_id: inspection.id,
|
||
user_message: inputMessage,
|
||
chat_history: messages,
|
||
context_photos: contextPhotos,
|
||
assistant_prompt: config.assistant_prompt || '',
|
||
assistant_instructions: config.assistant_instructions || '',
|
||
response_length: config.response_length || 'medium',
|
||
vehicle_info: {
|
||
brand: inspection.vehicle_brand,
|
||
model: inspection.vehicle_model,
|
||
plate: inspection.vehicle_plate,
|
||
km: inspection.vehicle_km
|
||
}
|
||
}
|
||
|
||
console.log('📤 Enviando a chat IA:', payload)
|
||
|
||
const response = await fetch(`${API_URL}/api/ai/chat-assistant`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Error en respuesta del servidor')
|
||
}
|
||
|
||
const data = await response.json()
|
||
console.log('📥 Respuesta de IA:', data)
|
||
|
||
const assistantMessage = {
|
||
role: 'assistant',
|
||
content: data.response,
|
||
timestamp: new Date().toISOString(),
|
||
confidence: data.confidence
|
||
}
|
||
|
||
setMessages(prev => [...prev, assistantMessage])
|
||
|
||
} catch (error) {
|
||
console.error('Error al enviar mensaje:', error)
|
||
const errorMessage = {
|
||
role: 'assistant',
|
||
content: '❌ Error al comunicarse con el asistente. Por favor intenta nuevamente.',
|
||
timestamp: new Date().toISOString(),
|
||
isError: true
|
||
}
|
||
setMessages(prev => [...prev, errorMessage])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100] p-2 sm:p-4">
|
||
<div className="bg-white rounded-xl w-full max-w-4xl max-h-[95vh] flex flex-col shadow-2xl">
|
||
{/* Header */}
|
||
<div className="bg-gradient-to-r from-purple-600 to-blue-600 text-white p-4 sm:p-6 rounded-t-xl">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-3xl">💬</span>
|
||
<h3 className="text-lg sm:text-xl font-bold">Asistente IA</h3>
|
||
</div>
|
||
<p className="text-sm sm:text-base text-purple-100 line-clamp-2">
|
||
{question.text}
|
||
</p>
|
||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||
<span className="px-2 py-1 bg-white/20 rounded">
|
||
{Object.values(allAnswers).filter(a => a.photos?.length > 0).length} preguntas con fotos
|
||
</span>
|
||
<span className="px-2 py-1 bg-white/20 rounded">
|
||
{messages.length} mensajes
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white/80 hover:text-white text-3xl flex-shrink-0"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat Messages */}
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4 bg-gray-50">
|
||
{messages.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<div className="text-6xl mb-4">🤖</div>
|
||
<p className="text-gray-600 text-sm sm:text-base mb-2">
|
||
¡Hola! Soy tu asistente técnico.
|
||
</p>
|
||
<p className="text-gray-500 text-xs sm:text-sm">
|
||
He analizado las fotos anteriores. ¿En qué puedo ayudarte?
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((msg, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
<div
|
||
className={`max-w-[85%] sm:max-w-[75%] rounded-lg p-3 sm:p-4 ${
|
||
msg.role === 'user'
|
||
? 'bg-blue-600 text-white'
|
||
: msg.isError
|
||
? 'bg-red-50 border border-red-200 text-red-800'
|
||
: 'bg-white border border-gray-200 text-gray-800'
|
||
}`}
|
||
>
|
||
<div className="text-sm sm:text-base whitespace-pre-wrap break-words">
|
||
{msg.content}
|
||
</div>
|
||
<div
|
||
className={`text-xs mt-2 ${
|
||
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
|
||
}`}
|
||
>
|
||
{new Date(msg.timestamp).toLocaleTimeString('es-ES', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
{msg.confidence && (
|
||
<span className="ml-2">• Confianza: {Math.round(msg.confidence * 100)}%</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{loading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||
<div className="flex items-center gap-2 text-gray-600">
|
||
<div className="animate-spin h-4 w-4 border-2 border-purple-600 border-t-transparent rounded-full"></div>
|
||
<span className="text-sm">El asistente está pensando...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div className="border-t p-3 sm:p-4 bg-white rounded-b-xl">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={inputMessage}
|
||
onChange={(e) => setInputMessage(e.target.value)}
|
||
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||
placeholder="Escribe tu pregunta..."
|
||
disabled={loading}
|
||
className="flex-1 px-3 sm:px-4 py-2 sm:py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm sm:text-base disabled:bg-gray-100"
|
||
/>
|
||
<button
|
||
onClick={sendMessage}
|
||
disabled={!inputMessage.trim() || loading}
|
||
className="px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition text-sm sm:text-base font-semibold"
|
||
>
|
||
Enviar
|
||
</button>
|
||
</div>
|
||
{config.max_messages && messages.length >= config.max_messages && (
|
||
<p className="text-xs text-amber-600 mt-2">
|
||
⚠️ Has alcanzado el límite de {config.max_messages} mensajes
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente de Gestión de Usuarios (Admin)
|
||
function UsersTab({ user }) {
|
||
const [users, setUsers] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [editingUser, setEditingUser] = useState(null)
|
||
const [showInactive, setShowInactive] = useState(false)
|
||
const [formData, setFormData] = useState({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
employee_code: '',
|
||
role: 'mechanic'
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadUsers()
|
||
}, [])
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/users', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setUsers(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading users:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateUser = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/users', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(formData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateForm(false)
|
||
setFormData({ username: '', email: '', password: '', employee_code: '', role: 'mechanic' })
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al crear usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating user:', error)
|
||
alert('Error al crear usuario')
|
||
}
|
||
}
|
||
|
||
const handleUpdateUser = async (userId, updates) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(updates)
|
||
})
|
||
|
||
if (response.ok) {
|
||
setEditingUser(null)
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al actualizar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating user:', error)
|
||
alert('Error al actualizar usuario')
|
||
}
|
||
}
|
||
|
||
const handleDeactivateUser = async (userId) => {
|
||
if (!confirm('¿Está seguro que desea desactivar este usuario?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}/deactivate`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al desactivar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deactivating user:', error)
|
||
alert('Error al desactivar usuario')
|
||
}
|
||
}
|
||
|
||
const handleActivateUser = async (userId) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}/activate`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al activar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error activating user:', error)
|
||
alert('Error al activar usuario')
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-12">Cargando usuarios...</div>
|
||
}
|
||
|
||
const filteredUsers = showInactive ? users : users.filter(u => u.is_active)
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-2xl font-bold text-gray-800">Gestión de Usuarios</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowInactive(!showInactive)}
|
||
className={`px-4 py-2 rounded-lg transition ${
|
||
showInactive
|
||
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
{showInactive ? '👁️ Ocultar Inactivos' : '👁️🗨️ Mostrar Inactivos'}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
+ Nuevo Usuario
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lista de usuarios */}
|
||
<div className="grid gap-4">
|
||
{filteredUsers.map(u => (
|
||
<div key={u.id} className="bg-white rounded-lg shadow-md p-4">
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold">
|
||
{u.username.charAt(0).toUpperCase()}
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-gray-800">{u.username}</h3>
|
||
<p className="text-sm text-gray-500">{u.email}</p>
|
||
{u.employee_code && (
|
||
<p className="text-xs text-gray-400 mt-0.5">Nro Operario: {u.employee_code}</p>
|
||
)}
|
||
<div className="flex gap-2 mt-1">
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
u.role === 'admin'
|
||
? 'bg-purple-100 text-purple-700'
|
||
: u.role === 'asesor'
|
||
? 'bg-indigo-100 text-indigo-700'
|
||
: 'bg-blue-100 text-blue-700'
|
||
}`}>
|
||
{u.role === 'admin' ? '👑 Admin' : u.role === 'asesor' ? '📊 Asesor' : '🔧 Mecánico'}
|
||
</span>
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
u.is_active
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-red-100 text-red-700'
|
||
}`}>
|
||
{u.is_active ? 'Activo' : 'Inactivo'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{user.role === 'admin' && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setEditingUser(u)}
|
||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
{u.id !== user.id && (
|
||
<>
|
||
{u.is_active ? (
|
||
<button
|
||
onClick={() => handleDeactivateUser(u.id)}
|
||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||
>
|
||
Desactivar
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleActivateUser(u.id)}
|
||
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||
>
|
||
Activar
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Modal de creación */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||
<h3 className="text-xl font-bold mb-4">Nuevo Usuario</h3>
|
||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre de usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.username}
|
||
onChange={(e) => setFormData({...formData, username: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={formData.email}
|
||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nro Operario <span className="text-gray-400 text-xs">(opcional)</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.employee_code || ''}
|
||
onChange={(e) => setFormData({...formData, employee_code: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
placeholder="Código de operario"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Contraseña
|
||
</label>
|
||
<input
|
||
type="password"
|
||
required
|
||
value={formData.password}
|
||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Rol
|
||
</label>
|
||
<select
|
||
value={formData.role}
|
||
onChange={(e) => setFormData({...formData, role: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="mechanic">Mecánico</option>
|
||
<option value="admin">Administrador</option>
|
||
<option value="asesor">Asesor</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateForm(false)
|
||
setFormData({ username: '', email: '', password: '', employee_code: '', role: 'mechanic' })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Crear Usuario
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal de edición */}
|
||
{editingUser && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||
<h3 className="text-xl font-bold mb-4">Editar Usuario</h3>
|
||
<form onSubmit={(e) => {
|
||
e.preventDefault()
|
||
const updates = {
|
||
username: editingUser.username,
|
||
email: editingUser.email,
|
||
employee_code: editingUser.employee_code,
|
||
role: editingUser.role
|
||
}
|
||
handleUpdateUser(editingUser.id, updates)
|
||
}} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre de usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={editingUser.username}
|
||
onChange={(e) => setEditingUser({...editingUser, username: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={editingUser.email}
|
||
onChange={(e) => setEditingUser({...editingUser, email: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nro Operario <span className="text-gray-400 text-xs">(opcional)</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editingUser.employee_code || ''}
|
||
onChange={(e) => setEditingUser({...editingUser, employee_code: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
placeholder="Código de operario"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Rol
|
||
</label>
|
||
<select
|
||
value={editingUser.role}
|
||
onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="mechanic">Mecánico</option>
|
||
<option value="admin">Administrador</option>
|
||
<option value="asesor">Asesor</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="border-t pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const newPassword = prompt('Ingrese la nueva contraseña:')
|
||
if (newPassword && newPassword.length >= 6) {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${editingUser.id}/password`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ new_password: newPassword })
|
||
})
|
||
|
||
if (response.ok) {
|
||
alert('Contraseña actualizada correctamente')
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al actualizar contraseña')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar contraseña')
|
||
}
|
||
} else if (newPassword !== null) {
|
||
alert('La contraseña debe tener al menos 6 caracteres')
|
||
}
|
||
}}
|
||
className="w-full px-4 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition"
|
||
>
|
||
🔑 Resetear Contraseña
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingUser(null)}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Guardar Cambios
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente de Reportes
|
||
function ReportsTab({ user }) {
|
||
const [dashboardData, setDashboardData] = useState(null)
|
||
const [inspections, setInspections] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [mechanics, setMechanics] = useState([])
|
||
const [checklists, setChecklists] = useState([])
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
const [filters, setFilters] = useState({
|
||
date: '',
|
||
mechanicId: '',
|
||
checklistId: '',
|
||
vehiclePlate: ''
|
||
})
|
||
const [appliedFilters, setAppliedFilters] = useState({
|
||
date: '',
|
||
mechanicId: '',
|
||
checklistId: '',
|
||
vehiclePlate: ''
|
||
})
|
||
|
||
const loadMechanics = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/users`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setMechanics(data.filter(u => u.role === 'mechanic' || u.role === 'mecanico' || u.role === 'admin'))
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading mechanics:', error)
|
||
}
|
||
}
|
||
|
||
const loadChecklists = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/checklists`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setChecklists(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading checklists:', error)
|
||
}
|
||
}
|
||
|
||
const loadDashboard = async (filtersToApply) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
let url = `${API_URL}/api/reports/dashboard?`
|
||
if (filtersToApply.date) {
|
||
url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&`
|
||
}
|
||
if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&`
|
||
|
||
console.log('Dashboard URL:', url)
|
||
console.log('Filters applied:', filtersToApply)
|
||
|
||
const response = await fetch(url, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log('Dashboard data received:', data)
|
||
setDashboardData(data)
|
||
} else {
|
||
console.error('Dashboard request failed:', response.status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading dashboard:', error)
|
||
}
|
||
}
|
||
|
||
const loadInspections = async (filtersToApply) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
let url = `${API_URL}/api/reports/inspections?`
|
||
if (filtersToApply.date) {
|
||
url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&`
|
||
}
|
||
if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&`
|
||
|
||
console.log('Inspections URL:', url)
|
||
|
||
const response = await fetch(url, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
let data = await response.json()
|
||
console.log('Inspections data received:', data.length, 'items')
|
||
console.log('First inspection data:', data[0])
|
||
// Filtrar por checklist en el frontend
|
||
if (filtersToApply.checklistId) {
|
||
const filtered = data.filter(i => {
|
||
console.log('Comparing:', i.checklist_id, '===', parseInt(filtersToApply.checklistId))
|
||
return i.checklist_id === parseInt(filtersToApply.checklistId)
|
||
})
|
||
data = filtered
|
||
console.log('After checklist filter:', data.length, 'items')
|
||
}
|
||
// Filtrar por matrícula en el frontend
|
||
if (filtersToApply.vehiclePlate) {
|
||
const filtered = data.filter(i =>
|
||
i.vehicle_plate && i.vehicle_plate.toLowerCase().includes(filtersToApply.vehiclePlate.toLowerCase())
|
||
)
|
||
data = filtered
|
||
console.log('After plate filter:', data.length, 'items')
|
||
}
|
||
setInspections(data)
|
||
} else {
|
||
console.error('Inspections request failed:', response.status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading inspections:', error)
|
||
}
|
||
}
|
||
|
||
const applyFilters = async () => {
|
||
setLoading(true)
|
||
setAppliedFilters(filters)
|
||
setCurrentPage(1) // Reset a página 1 al aplicar filtros
|
||
await Promise.all([loadDashboard(filters), loadInspections(filters)])
|
||
setLoading(false)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const loadInitialData = async () => {
|
||
setLoading(true)
|
||
// Cargar mecánicos y checklists primero
|
||
await Promise.all([loadMechanics(), loadChecklists()])
|
||
// Luego cargar datos sin filtros (filtros vacíos = todos los datos)
|
||
const emptyFilters = { date: '', mechanicId: '', checklistId: '', vehiclePlate: '' }
|
||
await Promise.all([loadDashboard(emptyFilters), loadInspections(emptyFilters)])
|
||
setLoading(false)
|
||
}
|
||
loadInitialData()
|
||
}, [])
|
||
|
||
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">
|
||
<h3 className="text-lg font-semibold mb-4">🔍 Filtros</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Fecha
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={filters.date}
|
||
onChange={(e) => setFilters({...filters, date: 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>
|
||
<select
|
||
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"
|
||
>
|
||
<option value="">Todos los mecánicos</option>
|
||
{mechanics.map(mechanic => (
|
||
<option key={mechanic.id} value={mechanic.id}>
|
||
{mechanic.full_name || mechanic.username} ({mechanic.role})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Checklist
|
||
</label>
|
||
<select
|
||
value={filters.checklistId}
|
||
onChange={(e) => setFilters({...filters, checklistId: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
>
|
||
<option value="">Todos los checklists</option>
|
||
{checklists.map(checklist => (
|
||
<option key={checklist.id} value={checklist.id}>
|
||
{checklist.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Matrícula
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={filters.vehiclePlate}
|
||
onChange={(e) => setFilters({...filters, vehiclePlate: e.target.value})}
|
||
placeholder="Ej: ABC123"
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<button
|
||
onClick={applyFilters}
|
||
className="w-full px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Aplicar Filtros
|
||
</button>
|
||
</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>
|
||
|
||
{/* Información de paginación */}
|
||
<div className="text-sm text-gray-600 mb-4">
|
||
Mostrando {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, inspections.length)} de {inspections.length} inspecciones
|
||
</div>
|
||
|
||
<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>
|
||
<th className="text-center py-2 px-4">Acciones</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{inspections.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage).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>
|
||
<td className="py-2 px-4 text-center">
|
||
<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-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition"
|
||
title="Exportar PDF"
|
||
>
|
||
📄
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Controles de paginación */}
|
||
{inspections.length > itemsPerPage && (
|
||
<div className="flex items-center justify-center gap-2 mt-6">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
← Anterior
|
||
</button>
|
||
|
||
<div className="flex gap-1">
|
||
{[...Array(Math.ceil(inspections.length / itemsPerPage))].map((_, index) => {
|
||
const page = index + 1
|
||
const totalPages = Math.ceil(inspections.length / itemsPerPage)
|
||
// Mostrar solo páginas cercanas a la actual
|
||
if (
|
||
page === 1 ||
|
||
page === totalPages ||
|
||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||
) {
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-3 py-2 rounded-lg ${
|
||
currentPage === page
|
||
? 'bg-blue-600 text-white'
|
||
: 'border border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
} else if (page === currentPage - 2 || page === currentPage + 2) {
|
||
return <span key={page} className="px-2 py-2">...</span>
|
||
}
|
||
return null
|
||
})}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(Math.ceil(inspections.length / itemsPerPage), prev + 1))}
|
||
disabled={currentPage === Math.ceil(inspections.length / itemsPerPage)}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Siguiente →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
export default App
|