Files
checklist/frontend/src/App.jsx
ronalds 7f2e9add29 Resumen de Cambios Implementados
Backend v1.2.1
Mejoras en gestión de API keys multi-proveedor:

Nuevo endpoint /api/ai/api-keys: Retorna todas las API keys guardadas por proveedor (enmascaradas para seguridad)

Formato: {"openai": {"has_key": true, "masked_key": "sk-proj...xyz", "is_active": false}}
Solo administradores pueden acceder
Endpoint /api/ai/configuration mejorado:

Ahora preserva API keys existentes cuando se cambia de proveedor
Si ya existe configuración para un proveedor, solo actualiza el modelo y activa ese proveedor
Solo requiere API key nueva si no existe configuración previa para ese proveedor
Validación: no acepta API keys vacías para nuevos proveedores
Persistencia de configuraciones:

Cada proveedor (OpenAI, Anthropic, Gemini) mantiene su registro en la base de datos
Solo uno tiene is_active=True a la vez
Al cambiar de proveedor, se desactiva el anterior pero NO se elimina
Frontend v1.2.6
UX mejorada para configuración de IA:

Indicadores visuales en botones de proveedor:

Badge "✓ ACTIVO" en verde para el proveedor actualmente activo
Badge "Configurado" en gris para proveedores con API key guardada pero inactivos
Sin badges para proveedores no configurados
Selector de modelos inteligente:

Solo muestra modelo seleccionado si el proveedor está activo
Al hacer click en un proveedor inactivo, NO se pre-selecciona ningún modelo
Solo al GUARDAR se activa el proveedor con el modelo seleccionado
Input de API key con contexto:

Muestra key enmascarada si ya existe: ✓ Ya tienes una API key guardada: sk-proj...xyz
Permite dejar vacío para mantener la key actual
Solo requiere key nueva si el proveedor no tiene una guardada
Flujo de trabajo mejorado:

Click en proveedor → Cambia tab de formulario
Si ya tiene key guardada → Se muestra enmascarada, puede mantenerla
Seleccionar modelo → Click en "Guardar Configuración"
Solo entonces se ACTIVA ese proveedor y modelo
Beneficios
No re-ingresar API keys: Al cambiar entre proveedores, las keys se preservan
Claridad visual: Solo el proveedor activo muestra badge verde y modelo seleccionado
Seguridad: API keys enmascaradas en la UI (sk-proj...xyz)
Flexibilidad: Configurar los 3 proveedores y cambiar entre ellos sin perder configuración
Versiones actualizadas:

Backend: 1.2.0 → 1.2.1
Frontend: 1.2.5 → 1.2.6
Service Worker: cache v1.2.6
2025-12-04 11:52:38 -03:00

6889 lines
288 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
});
// Estado para guardar todas las API keys y proveedor activo
const [savedApiKeys, setSavedApiKeys] = useState({
openai: '',
anthropic: '',
gemini: ''
});
const [activeProvider, setActiveProvider] = useState(null); // Proveedor actualmente activo
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 activa
const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (configRes.ok) {
const config = await configRes.json();
setAiConfig(config);
setActiveProvider(config.provider);
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');
setActiveProvider(null);
}
// Cargar todas las API keys guardadas
const keysRes = await fetch(`${API_URL}/api/ai/api-keys`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (keysRes.ok) {
const keys = await keysRes.json();
const newSavedKeys = {
openai: '',
anthropic: '',
gemini: ''
};
// Las keys vienen enmascaradas, solo indicamos que existen
Object.keys(keys).forEach(provider => {
if (keys[provider].has_key) {
newSavedKeys[provider] = keys[provider].masked_key;
}
});
setSavedApiKeys(newSavedKeys);
}
} 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' })}
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>
{activeProvider === 'openai' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.openai && activeProvider !== 'openai' && (
<div className="mt-2 text-xs text-gray-500">Configurado</div>
)}
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, provider: 'anthropic' })}
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'anthropic' ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-gray-400'}`}
>
<div className="text-4xl mb-2">🧠</div>
<div className="font-semibold">Anthropic Claude</div>
<div className="text-xs text-gray-600 mt-1">Sonnet, Opus, Haiku</div>
{activeProvider === 'anthropic' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.anthropic && activeProvider !== 'anthropic' && (
<div className="mt-2 text-xs text-gray-500">Configurado</div>
)}
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, provider: 'gemini' })}
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>
{activeProvider === 'gemini' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.gemini && activeProvider !== 'gemini' && (
<div className="mt-2 text-xs text-gray-500">Configurado</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' : formData.provider === 'anthropic' ? 'Anthropic API Key' : 'Google AI API Key'}
</label>
{savedApiKeys[formData.provider] && (
<div className="mb-2 text-xs text-green-600">
Ya tienes una API key guardada: {savedApiKeys[formData.provider]}
<span className="text-gray-500 ml-2">(Deja vacío para mantener la actual o ingresa una nueva)</span>
</div>
)}
<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-...' : formData.provider === 'anthropic' ? 'sk-ant-...' : 'AIza...'}
required={!savedApiKeys[formData.provider]}
/>
<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></>
) : formData.provider === 'anthropic' ? (
<>Obtén tu API key en <a href="https://console.anthropic.com/" target="_blank" className="text-purple-600 hover:underline">Anthropic Console</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) => {
// Solo marcar como checked si este proveedor está activo Y es el modelo activo
const isActiveModel = activeProvider === formData.provider && formData.model_name === model.id;
return (
<label key={model.id} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${isActiveModel ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
<input
type="radio"
name="model_name"
value={model.id}
checked={isActiveModel}
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' && '💡 Asistente sugiere respuestas, el mecánico confirma'}
{editChecklistData.ai_mode === 'copilot' && '🤖 Asistente 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"> 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 Ayutec</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 - Solo subir archivos nuevos (File/Blob), no URLs existentes
if (answer.photos.length > 0) {
for (const photoFile of answer.photos) {
// Verificar si es un archivo nuevo y no una URL de foto ya subida
if (photoFile instanceof File || photoFile instanceof Blob) {
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
})
}
// Si es string (URL), ya está subida, no hacer nada
}
}
// 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 del asistente)')
} 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"
>
&#8592;
</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"
>
&#8594;
</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"
required
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">
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)
// SIEMPRE inicializar - cargar historial guardado O array vacío para nueva sesión
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</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) => {
// Determinar si es un archivo nuevo (File/Blob) o una URL existente (string)
const isFile = photo instanceof File || photo instanceof Blob
const isPDF = isFile ? photo.type === 'application/pdf' : photo.endsWith('.pdf')
const photoURL = isFile ? URL.createObjectURL(photo) : photo
return (
<div key={index} className="relative group">
{isPDF ? (
<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={photoURL}
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">
{isPDF ? (isFile ? photo.name : 'PDF') : `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` +
`Asistente 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` +
`Asistente 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 [selectedImage, setSelectedImage] = useState(null) // Para lightbox
const chatEndRef = useRef(null)
const fileInputRef = useRef(null)
const config = question.options || {}
// Auto-scroll al final
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Limpiar URLs temporales al desmontar el componente
useEffect(() => {
return () => {
attachedFiles.forEach(fileWrapper => {
if (fileWrapper?.preview) {
URL.revokeObjectURL(fileWrapper.preview)
}
})
}
}, [attachedFiles])
// 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
})
// Crear objetos con File y URL temporal para preview
const filesWithPreview = validFiles.map(file => ({
file: file,
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
name: file.name,
type: file.type,
size: file.size
}))
setAttachedFiles(prev => [...prev, ...filesWithPreview])
}
const removeAttachedFile = (index) => {
setAttachedFiles(prev => {
const fileToRemove = prev[index]
// Liberar URL temporal si existe
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview)
}
return 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,
preview: f.preview // Guardar URL temporal para mostrar en chat
}))
}
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 (extraer el objeto File del wrapper)
currentFiles.forEach((fileWrapper, index) => {
formData.append('files', fileWrapper.file || fileWrapper)
})
// 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">
<img
src="/ayutec_logo.png"
alt="Ayutec"
className="w-8 h-8 object-contain bg-white rounded p-1"
/>
<h3 className="text-lg sm:text-xl font-bold">Asistente Ayutec</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="mb-4 flex justify-center">
<img
src="/ayutec_logo.png"
alt="Ayutec"
className="w-16 h-16 object-contain bg-white rounded-lg p-2 shadow-lg"
/>
</div>
<p className="text-gray-600 text-sm sm:text-base mb-2">
¡Hola! Soy tu Asistente Ayutec.
</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-3 space-y-2">
{msg.files.map((file, fIdx) => (
<div key={fIdx}>
{file.type.startsWith('image/') && file.preview ? (
<div className="space-y-1">
<img
src={file.preview}
alt={file.name}
className="rounded-lg max-w-full h-auto max-h-64 object-contain cursor-zoom-in hover:opacity-90 transition"
onClick={() => setSelectedImage({ url: file.preview, name: file.name })}
/>
<div className={`text-xs flex items-center gap-1 ${msg.role === 'user' ? 'text-blue-100' : 'text-gray-500'}`}>
<span>🖼</span>
<span className="truncate">{file.name}</span>
<span>({(file.size / 1024).toFixed(1)} KB)</span>
</div>
</div>
) : (
<div 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>
)}
<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((fileWrapper, idx) => (
<div key={idx} className="relative bg-gray-100 rounded-lg overflow-hidden">
{fileWrapper.preview ? (
<div className="relative">
<img
src={fileWrapper.preview}
alt={fileWrapper.name}
className="h-20 w-20 object-cover"
/>
<button
onClick={() => removeAttachedFile(idx)}
className="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold hover:bg-red-700 shadow-lg"
type="button"
>
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-1 py-0.5 truncate">
{fileWrapper.name}
</div>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 text-sm">
<span>📄</span>
<span className="max-w-[150px] truncate">{fileWrapper.name}</span>
<button
onClick={() => removeAttachedFile(idx)}
className="text-red-600 hover:text-red-800 font-bold"
type="button"
>
</button>
</div>
)}
</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>
{/* Lightbox para visualizar imágenes */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/95 z-[200] flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<button
onClick={() => setSelectedImage(null)}
className="absolute top-4 right-4 text-white/80 hover:text-white text-4xl font-bold z-10 bg-black/50 rounded-full w-12 h-12 flex items-center justify-center"
>
×
</button>
<div className="relative max-w-[95vw] max-h-[95vh] overflow-auto">
<img
src={selectedImage.url}
alt={selectedImage.name}
className="max-w-full max-h-[95vh] object-contain cursor-zoom-out"
onClick={(e) => {
e.stopPropagation()
setSelectedImage(null)
}}
/>
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/70 text-white px-4 py-2 rounded-lg text-sm">
🖼 {selectedImage.name}
</div>
</div>
</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