Cambios en el Backend (v1.0.96) Nuevo campo expected_answer en el análisis de IA: La IA ahora retorna cuál debería ser la respuesta correcta según lo que observa en la imagen Se incluyen las opciones de respuesta disponibles en el prompt para que la IA elija la correcta Extracción de opciones de pregunta: El sistema extrae las opciones disponibles (Buen Estado, Mal Estado, etc.) Las envía a la IA para que determine cuál es la respuesta esperada Cambios en el Frontend Validación antes de continuar: Cuando el mecánico intenta avanzar a la siguiente pregunta o firmar El sistema compara su respuesta con expected_answer del análisis de IA Si NO coinciden, aparece un popup con:
6698 lines
280 KiB
JavaScript
6698 lines
280 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 ReactMarkdown from 'react-markdown'
|
||
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) {
|
||
console.log('⚠️ Hay una actualización esperando');
|
||
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 - MOSTRAR MODAL, NO ACTIVAR AUTOMÁTICAMENTE
|
||
console.log('✨ Nueva versión instalada - esperando confirmación del usuario');
|
||
setWaitingWorker(newWorker);
|
||
setUpdateAvailable(true);
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.error('❌ Error al registrar Service Worker:', error);
|
||
});
|
||
|
||
// Escuchar cambios de controlador (cuando se activa nueva versión)
|
||
// SOLO se dispara DESPUÉS de que el usuario presione el botón
|
||
let refreshing = false;
|
||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||
if (!refreshing) {
|
||
refreshing = true;
|
||
console.log('🔄 Controlador cambiado, recargando página...');
|
||
window.location.reload();
|
||
}
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// Función para actualizar la app - SOLO cuando el usuario presiona el botón
|
||
const handleUpdate = () => {
|
||
if (waitingWorker) {
|
||
console.log('👆 Usuario confirmó actualización - activando nueva versión...');
|
||
// Enviar mensaje al service worker para que se active
|
||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||
// El controllerchange listener manejará la recarga
|
||
}
|
||
};
|
||
|
||
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">
|
||
<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,
|
||
photo_requirement: 'optional',
|
||
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,
|
||
photo_requirement: 'optional',
|
||
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,
|
||
photo_requirement: question.photo_requirement || 'optional',
|
||
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,
|
||
photo_requirement: formData.photo_requirement,
|
||
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,
|
||
photo_requirement: 'optional',
|
||
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'
|
||
// Hacer semi-transparente y añadir borde para feedback visual
|
||
e.currentTarget.style.opacity = '0.4'
|
||
e.currentTarget.style.transform = 'scale(0.98)'
|
||
e.currentTarget.classList.add('ring-2', 'ring-purple-500')
|
||
}
|
||
|
||
const handleDragEnd = (e) => {
|
||
e.currentTarget.style.opacity = '1'
|
||
e.currentTarget.style.transform = 'scale(1)'
|
||
e.currentTarget.classList.remove('ring-2', 'ring-purple-500')
|
||
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
|
||
}
|
||
|
||
// Preparar datos para el backend
|
||
let reorderData = []
|
||
|
||
if (!draggedIsChild) {
|
||
// CASO 1: Mover pregunta padre (con sus hijos)
|
||
|
||
// Obtener todas las preguntas padre ordenadas
|
||
const parentQuestions = questions.filter(q => !q.parent_question_id)
|
||
.sort((a, b) => a.order - b.order)
|
||
|
||
const draggedIdx = parentQuestions.findIndex(q => q.id === draggedQuestion.id)
|
||
const targetIdx = parentQuestions.findIndex(q => q.id === targetQuestion.id)
|
||
|
||
// Reordenar la lista de padres
|
||
const reorderedParents = [...parentQuestions]
|
||
const [movedParent] = reorderedParents.splice(draggedIdx, 1)
|
||
reorderedParents.splice(targetIdx, 0, movedParent)
|
||
|
||
// Asignar nuevos valores de order espaciados (cada padre tiene +100, hijos usan +1, +2, +3...)
|
||
let currentOrder = 0
|
||
|
||
reorderedParents.forEach(parent => {
|
||
// Asignar order al padre
|
||
reorderData.push({
|
||
question_id: parent.id,
|
||
new_order: currentOrder
|
||
})
|
||
|
||
currentOrder += 1
|
||
|
||
// Obtener y ordenar los hijos de este padre
|
||
const children = questions.filter(q => q.parent_question_id === parent.id)
|
||
.sort((a, b) => a.order - b.order)
|
||
|
||
// Asignar order a cada hijo
|
||
children.forEach(child => {
|
||
reorderData.push({
|
||
question_id: child.id,
|
||
new_order: currentOrder
|
||
})
|
||
currentOrder += 1
|
||
})
|
||
|
||
// Dejar espacio para el siguiente padre (saltar a siguiente decena)
|
||
currentOrder = Math.ceil(currentOrder / 10) * 10
|
||
})
|
||
|
||
} else {
|
||
// CASO 2: Mover subpregunta (solo dentro del mismo padre)
|
||
|
||
const parentId = draggedQuestion.parent_question_id
|
||
const siblings = questions.filter(q => q.parent_question_id === parentId)
|
||
.sort((a, b) => a.order - b.order)
|
||
|
||
const draggedIdx = siblings.findIndex(q => q.id === draggedQuestion.id)
|
||
const targetIdx = siblings.findIndex(q => q.id === targetQuestion.id)
|
||
|
||
// Reordenar hermanos
|
||
const reorderedSiblings = [...siblings]
|
||
const [movedChild] = reorderedSiblings.splice(draggedIdx, 1)
|
||
reorderedSiblings.splice(targetIdx, 0, movedChild)
|
||
|
||
// Mantener el order base del primer hermano y solo incrementar
|
||
const baseOrder = Math.min(...siblings.map(s => s.order))
|
||
|
||
reorderedSiblings.forEach((child, index) => {
|
||
reorderData.push({
|
||
question_id: child.id,
|
||
new_order: baseOrder + 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)
|
||
}
|
||
|
||
// Primero ordenar todas las preguntas por el campo 'order' para mantener el orden del backend
|
||
const sortedQuestions = [...questions].sort((a, b) => a.order - b.order)
|
||
|
||
// Luego agrupar por sección manteniendo el orden
|
||
const questionsBySection = sortedQuestions.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>
|
||
|
||
{/* Subpreguntas y Preguntas Condicionales - 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">
|
||
📋 Subpreguntas y Preguntas Condicionales (hasta 5 niveles)
|
||
</h4>
|
||
|
||
<div className="space-y-4">
|
||
{/* Selector de pregunta padre - SIEMPRE disponible */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Pregunta padre (opcional)
|
||
</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 && (!editingQuestion || q.id !== editingQuestion.id)
|
||
})
|
||
.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">
|
||
Si seleccionas una pregunta padre, esta pregunta se mostrará como subpregunta debajo de ella
|
||
</p>
|
||
</div>
|
||
|
||
{/* Condición - Solo si hay padre y el padre es boolean/single_choice */}
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
if (!parentQ) return null
|
||
|
||
const config = parentQ.options || {}
|
||
const parentType = config.type || parentQ.type
|
||
const isConditionalParent = ['boolean', 'single_choice'].includes(parentType)
|
||
|
||
if (!isConditionalParent) {
|
||
return (
|
||
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
|
||
<p className="text-sm text-green-800">
|
||
✓ Esta subpregunta aparecerá <strong>SIEMPRE</strong> debajo de la pregunta padre seleccionada
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
⚡ Condición (opcional) - Mostrar si la respuesta es:
|
||
</label>
|
||
<select
|
||
value={formData.show_if_answer || ''}
|
||
onChange={(e) => setFormData({ ...formData, show_if_answer: e.target.value || null })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
>
|
||
<option value="">Siempre visible (sin condición)</option>
|
||
{config.choices ? (
|
||
config.choices.map((choice, idx) => (
|
||
<option key={idx} value={choice.value}>
|
||
Solo si es: {choice.label}
|
||
</option>
|
||
))
|
||
) : (
|
||
// Compatibilidad con tipos antiguos
|
||
parentType === 'pass_fail' ? [
|
||
<option key="pass" value="pass">Solo si es: ✓ Pasa</option>,
|
||
<option key="fail" value="fail">Solo si es: ✗ Falla</option>
|
||
] : [
|
||
<option key="good" value="good">Solo si es: ✓ Bueno</option>,
|
||
<option key="bad" value="bad">Solo si es: ✗ Malo</option>
|
||
]
|
||
)}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{formData.show_if_answer
|
||
? '⚡ Se mostrará SOLO cuando la respuesta del padre coincida'
|
||
: '✓ Se mostrará SIEMPRE debajo de la pregunta padre'}
|
||
</p>
|
||
</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={`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>
|
||
</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>
|
||
|
||
{/* Configuración de fotos/archivos */}
|
||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-indigo-900 mb-3">
|
||
📷 Fotos y Archivos Adjuntos
|
||
</h4>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Requisito de adjuntos
|
||
</label>
|
||
<select
|
||
value={formData.photo_requirement || 'optional'}
|
||
onChange={(e) => setFormData({ ...formData, photo_requirement: 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"
|
||
>
|
||
<option value="none">🚫 No permitir adjuntos</option>
|
||
<option value="optional">📎 Opcional (puede adjuntar si quiere)</option>
|
||
<option value="required">⚠️ Obligatorio (debe adjuntar)</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{formData.photo_requirement === 'none' && '• No se podrán adjuntar fotos/archivos'}
|
||
{formData.photo_requirement === 'optional' && '• El mecánico puede adjuntar si lo desea'}
|
||
{formData.photo_requirement === 'required' && '• El mecánico DEBE adjuntar al menos 1 archivo'}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Máx. archivos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
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.photo_requirement === 'none'}
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Cantidad máxima de fotos/PDFs permitidos
|
||
</p>
|
||
</div>
|
||
</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)
|
||
.sort(([, questionsA], [, questionsB]) => {
|
||
// Ordenar secciones por el 'order' mínimo de sus preguntas
|
||
const minOrderA = Math.min(...questionsA.map(q => q.order))
|
||
const minOrderB = Math.min(...questionsB.map(q => q.order))
|
||
return minOrderA - minOrderB
|
||
})
|
||
.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 duration-200 relative ${
|
||
isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : ''
|
||
} ${
|
||
draggedQuestion?.id === question.id ? 'opacity-40 scale-95' : ''
|
||
} ${
|
||
dragOverQuestion?.id === question.id
|
||
? 'bg-purple-50 border-t-4 border-purple-500 shadow-lg pt-8'
|
||
: ''
|
||
}`}
|
||
>
|
||
{/* Indicador visual de zona de drop */}
|
||
{dragOverQuestion?.id === question.id && (
|
||
<div className="absolute -top-4 left-0 right-0 flex items-center justify-center">
|
||
<div className="bg-purple-500 text-white px-4 py-1 rounded-full text-xs font-semibold flex items-center gap-2 shadow-lg">
|
||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||
</svg>
|
||
Se moverá ANTES de esta pregunta
|
||
</div>
|
||
</div>
|
||
)}
|
||
<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 px-2 py-1 rounded ${
|
||
question.show_if_answer
|
||
? 'bg-blue-200 text-blue-800'
|
||
: 'bg-green-200 text-green-800'
|
||
}`}>
|
||
{question.show_if_answer ? '⚡ Condicional' : '📎 Subpregunta'}
|
||
</span>
|
||
)}
|
||
<p className="text-gray-900">{question.text}</p>
|
||
</div>
|
||
{isSubQuestion && parentQuestion && (
|
||
<p className={`text-xs mt-1 ${question.show_if_answer ? 'text-blue-600' : 'text-green-600'}`}>
|
||
{question.show_if_answer
|
||
? `→ Aparece si #${question.parent_question_id} es ${question.show_if_answer}`
|
||
: `→ Siempre visible debajo de #${question.parent_question_id}`
|
||
}
|
||
</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.photo_requirement === 'required' && (
|
||
<span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
||
⚠️ Fotos obligatorias
|
||
</span>
|
||
)}
|
||
{question.photo_requirement === 'optional' && (
|
||
<span>📷 Máx {question.max_photos} fotos</span>
|
||
)}
|
||
{(!question.photo_requirement || question.allow_photos) && !question.photo_requirement && (
|
||
<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',
|
||
chat_history: answer.chatHistory || null // Agregar historial de chat
|
||
}
|
||
|
||
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 => {
|
||
const answer = answers[q.id]
|
||
|
||
// Para preguntas tipo photo_only, solo validar que tenga fotos
|
||
if (q.options?.type === 'photo_only') {
|
||
return !answer?.photos?.length
|
||
}
|
||
|
||
// Validar fotos obligatorias (photo_requirement = 'required')
|
||
if (q.photo_requirement === 'required' && (!answer?.photos || answer.photos.length === 0)) {
|
||
return true // Falta adjuntar fotos obligatorias
|
||
}
|
||
|
||
// Para otros tipos, validar que tenga respuesta
|
||
return !answer?.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
|
||
|
||
// If has parent but NO condition (show_if_answer is null/empty), always show if parent was answered
|
||
if (!q.show_if_answer) {
|
||
const parentAnswer = answers[q.parent_question_id]
|
||
return !!parentAnswer // Show if parent has any answer
|
||
}
|
||
|
||
// If has parent AND condition, check if parent answer matches
|
||
const parentAnswer = answers[q.parent_question_id]
|
||
if (!parentAnswer) return false
|
||
|
||
return parentAnswer.value === q.show_if_answer
|
||
})
|
||
.sort((a, b) => a.order - b.order) // Mantener orden del backend
|
||
}
|
||
|
||
// 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 ni ai_assistant */}
|
||
{currentQuestion.options?.type !== 'photo_only' && currentQuestion.options?.type !== 'ai_assistant' && (
|
||
<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>
|
||
)}
|
||
|
||
{/* Botón de Chat IA - Mostrar SIEMPRE si es tipo ai_assistant */}
|
||
{currentQuestion.options?.type === 'ai_assistant' && (
|
||
<div>
|
||
<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)}
|
||
/>
|
||
|
||
<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>
|
||
)}
|
||
|
||
{/* Observations - Mostrar solo si show_observations !== false */}
|
||
{currentQuestion.options?.type !== 'photo_only' &&
|
||
currentQuestion.options?.type !== 'ai_assistant' &&
|
||
currentQuestion.options?.show_observations !== false && (
|
||
<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.photo_requirement !== 'none' || currentQuestion.allow_photos) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Fotografías / Documentos
|
||
{currentQuestion.photo_requirement === 'required' && (
|
||
<span className="ml-2 text-xs text-red-600 font-semibold">
|
||
⚠️ OBLIGATORIO
|
||
</span>
|
||
)}
|
||
{currentQuestion.photo_requirement === 'optional' && (
|
||
<span className="ml-2 text-xs text-gray-600">
|
||
(opcional)
|
||
</span>
|
||
)}
|
||
{currentQuestion.max_photos && (
|
||
<span className="ml-2 text-xs text-gray-600">
|
||
- máx {currentQuestion.max_photos} archivo{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/*,application/pdf"
|
||
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={currentQuestion.photo_requirement === '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} archivo(s) cargado(s):
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{answers[currentQuestion.id].photos.map((photo, index) => (
|
||
<div key={index} className="relative group">
|
||
{photo.type === 'application/pdf' ? (
|
||
<div className="w-full h-24 flex flex-col items-center justify-center bg-red-50 border-2 border-red-300 rounded-lg">
|
||
<span className="text-3xl">📝</span>
|
||
<span className="text-xs text-red-700 mt-1">PDF</span>
|
||
</div>
|
||
) : (
|
||
<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">
|
||
{photo.type === 'application/pdf' ? photo.name : `Foto ${index + 1}`}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Analyze Button - Solo para ai_mode assisted/full y NO para ai_assistant */}
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') &&
|
||
currentQuestion.options?.type !== 'ai_assistant' && (
|
||
<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>
|
||
)}
|
||
</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
|
||
}
|
||
|
||
// NUEVA VALIDACIÓN: Verificar coherencia entre respuesta del mecánico y análisis de IA
|
||
const answer = answers[currentQuestion.id]
|
||
if (answer?.aiAnalysis && answer.aiAnalysis.length > 0 && answer.value) {
|
||
const aiData = answer.aiAnalysis[0]?.analysis
|
||
if (aiData?.expected_answer && aiData.expected_answer.toLowerCase() !== answer.value.toLowerCase()) {
|
||
const confirmChange = window.confirm(
|
||
`⚠️ ADVERTENCIA DE COHERENCIA\n\n` +
|
||
`Tu respuesta: "${answer.value}"\n` +
|
||
`Análisis de IA sugiere: "${aiData.expected_answer}"\n\n` +
|
||
`La imagen muestra:\n${aiData.observations || 'Sin detalles'}\n\n` +
|
||
`¿Deseas cambiar tu respuesta antes de continuar?\n\n` +
|
||
`• Presiona CANCELAR para revisar y cambiar tu respuesta\n` +
|
||
`• Presiona ACEPTAR para continuar con tu respuesta actual`
|
||
)
|
||
|
||
if (!confirmChange) {
|
||
// El mecánico quiere cambiar su respuesta
|
||
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
|
||
}
|
||
|
||
// NUEVA VALIDACIÓN: Verificar coherencia entre respuesta del mecánico y análisis de IA
|
||
const answer = answers[currentQuestion.id]
|
||
if (answer?.aiAnalysis && answer.aiAnalysis.length > 0 && answer.value) {
|
||
const aiData = answer.aiAnalysis[0]?.analysis
|
||
if (aiData?.expected_answer && aiData.expected_answer.toLowerCase() !== answer.value.toLowerCase()) {
|
||
const confirmChange = window.confirm(
|
||
`⚠️ ADVERTENCIA DE COHERENCIA\n\n` +
|
||
`Tu respuesta: "${answer.value}"\n` +
|
||
`Análisis de IA sugiere: "${aiData.expected_answer}"\n\n` +
|
||
`La imagen muestra:\n${aiData.observations || 'Sin detalles'}\n\n` +
|
||
`¿Deseas cambiar tu respuesta antes de completar?\n\n` +
|
||
`• Presiona CANCELAR para revisar y cambiar tu respuesta\n` +
|
||
`• Presiona ACEPTAR para continuar con tu respuesta actual`
|
||
)
|
||
|
||
if (!confirmChange) {
|
||
// El mecánico quiere cambiar su respuesta
|
||
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 [attachedFiles, setAttachedFiles] = useState([])
|
||
const chatEndRef = useRef(null)
|
||
const fileInputRef = useRef(null)
|
||
const config = question.options || {}
|
||
|
||
// Auto-scroll al final
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}, [messages])
|
||
|
||
// Manejar adjuntos de archivos
|
||
const handleFileAttach = (e) => {
|
||
const files = Array.from(e.target.files)
|
||
const validFiles = files.filter(file => {
|
||
const isImage = file.type.startsWith('image/')
|
||
const isPDF = file.type === 'application/pdf'
|
||
const isValid = isImage || isPDF
|
||
if (!isValid) {
|
||
alert(`⚠️ ${file.name}: Solo se permiten imágenes y PDFs`)
|
||
}
|
||
return isValid
|
||
})
|
||
setAttachedFiles(prev => [...prev, ...validFiles])
|
||
}
|
||
|
||
const removeAttachedFile = (index) => {
|
||
setAttachedFiles(prev => prev.filter((_, i) => i !== index))
|
||
}
|
||
|
||
// Enviar mensaje al asistente
|
||
const sendMessage = async () => {
|
||
if ((!inputMessage.trim() && attachedFiles.length === 0) || loading) return
|
||
|
||
const userMessage = {
|
||
role: 'user',
|
||
content: inputMessage || '📎 Archivos adjuntos',
|
||
timestamp: new Date().toISOString(),
|
||
files: attachedFiles.map(f => ({ name: f.name, type: f.type, size: f.size }))
|
||
}
|
||
setMessages(prev => [...prev, userMessage])
|
||
|
||
const currentFiles = attachedFiles
|
||
setInputMessage('')
|
||
setAttachedFiles([])
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Preparar FormData para enviar archivos
|
||
const formData = new FormData()
|
||
formData.append('question_id', question.id)
|
||
formData.append('inspection_id', inspection.id)
|
||
formData.append('user_message', inputMessage)
|
||
formData.append('chat_history', JSON.stringify(messages))
|
||
formData.append('assistant_prompt', config.assistant_prompt || '')
|
||
formData.append('assistant_instructions', config.assistant_instructions || '')
|
||
formData.append('response_length', config.response_length || 'medium')
|
||
|
||
// Adjuntar archivos
|
||
currentFiles.forEach((file, index) => {
|
||
formData.append('files', file)
|
||
})
|
||
|
||
// Adjuntar archivos
|
||
currentFiles.forEach((file, index) => {
|
||
formData.append('files', file)
|
||
})
|
||
|
||
// 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
|
||
})
|
||
})
|
||
}
|
||
})
|
||
|
||
formData.append('context_photos', JSON.stringify(contextPhotos))
|
||
formData.append('vehicle_info', JSON.stringify({
|
||
brand: inspection.vehicle_brand,
|
||
model: inspection.vehicle_model,
|
||
plate: inspection.vehicle_plate,
|
||
km: inspection.vehicle_km
|
||
}))
|
||
|
||
console.log('📤 Enviando a chat IA con archivos:', currentFiles.length)
|
||
|
||
const response = await fetch(`${API_URL}/api/ai/chat-assistant`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
// No incluir Content-Type, fetch lo establece automáticamente con FormData
|
||
},
|
||
body: formData
|
||
})
|
||
|
||
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'
|
||
}`}
|
||
>
|
||
{/* Renderizar contenido con Markdown para mensajes del asistente */}
|
||
{msg.role === 'assistant' && !msg.isError ? (
|
||
<div className="text-sm sm:text-base prose prose-sm max-w-none prose-headings:text-gray-800 prose-p:text-gray-800 prose-strong:text-gray-900 prose-code:text-gray-800 prose-pre:bg-gray-100 prose-pre:text-gray-800">
|
||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||
</div>
|
||
) : (
|
||
<div className="text-sm sm:text-base whitespace-pre-wrap break-words">
|
||
{msg.content}
|
||
</div>
|
||
)}
|
||
{/* Mostrar archivos adjuntos si existen */}
|
||
{msg.files && msg.files.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{msg.files.map((file, fIdx) => (
|
||
<div key={fIdx} className={`text-xs flex items-center gap-1 ${msg.role === 'user' ? 'text-blue-100' : 'text-gray-600'}`}>
|
||
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||
<span className="truncate">{file.name}</span>
|
||
<span>({(file.size / 1024).toFixed(1)} KB)</span>
|
||
</div>
|
||
))}
|
||
</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">
|
||
{/* Preview de archivos adjuntos */}
|
||
{attachedFiles.length > 0 && (
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{attachedFiles.map((file, idx) => (
|
||
<div key={idx} className="flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-lg text-sm">
|
||
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||
<span className="max-w-[150px] truncate">{file.name}</span>
|
||
<button
|
||
onClick={() => removeAttachedFile(idx)}
|
||
className="text-red-600 hover:text-red-800 font-bold"
|
||
type="button"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={handleFileAttach}
|
||
accept="image/*,application/pdf"
|
||
multiple
|
||
className="hidden"
|
||
/>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={loading}
|
||
className="px-3 py-2 sm:py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title="Adjuntar archivos (imágenes o PDFs)"
|
||
type="button"
|
||
>
|
||
📎
|
||
</button>
|
||
<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() && attachedFiles.length === 0) || 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
|