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 (
Cargando...
) } return (
{/* Modal de actualización disponible */} {updateAvailable && (
🔄

¡Nueva Actualización!

Hay una nueva versión disponible con mejoras y correcciones.
Por favor actualiza para continuar.

La página se recargará automáticamente

)} {!user ? ( ) : ( )}
) } // 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 (
{/* Header con Logo */}
{logoUrl ? ( Logo ) : (
Sin logo
)}

AYUTEC

Sistema Inteligente de Inspecciones

{/* Formulario */}
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 />
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 />
{error && (
⚠️ {error}
)}
) } // 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 (
{/* Sidebar */} {/* Main Content */}
{/* Header */}
{/* Botón hamburguesa (solo móvil) */} {/* Logo y Nombre del Sistema */}
{logoUrl ? ( Logo ) : (
Sin logo
)}

AYUTEC

Sistema Inteligente de Inspecciones

{/* Sección Activa */}
{activeTab === 'checklists' && '📋'} {activeTab === 'inspections' && '🔍'} {activeTab === 'users' && '👥'} {activeTab === 'reports' && '📊'} {activeTab === 'settings' && '⚙️'} {activeTab === 'checklists' && 'Checklists'} {activeTab === 'inspections' && 'Inspecciones'} {activeTab === 'users' && 'Usuarios'} {activeTab === 'reports' && 'Reportes'} {activeTab === 'settings' && 'Configuración'}
{/* Indicador móvil (solo icono) */}
{activeTab === 'checklists' && '📋'} {activeTab === 'inspections' && '🔍'} {activeTab === 'users' && '👥'} {activeTab === 'reports' && '📊'} {activeTab === 'settings' && '⚙️'}
{/* Content */}
{loading ? (
Cargando datos...
) : activeTab === 'checklists' ? ( ) : activeTab === 'inspections' ? ( ) : activeTab === 'settings' ? ( ) : activeTab === 'api-tokens' ? ( ) : activeTab === 'users' ? ( ) : activeTab === 'reports' ? ( ) : null}
{/* Modal de Inspección Activa */} {activeInspection && ( setActiveInspection(null)} onComplete={() => { setActiveInspection(null) loadData() }} /> )}
) } 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 (

Logo del Sistema

{logoUrl ? ( Logo ) : (
Sin logo
)}
{logoUploading && Subiendo...}

El logo se mostrará en el login y en la página principal.

Configuración de IA

Configura el proveedor y modelo de IA para análisis de imágenes

API Key

{savedApiKeys[formData.provider] && (
✓ Ya tienes una API key guardada: {savedApiKeys[formData.provider]} (Deja vacío para mantener la actual o ingresa una nueva)
)} 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]} />

{formData.provider === 'openai' ? ( <>Obtén tu API key en OpenAI Platform ) : formData.provider === 'anthropic' ? ( <>Obtén tu API key en Anthropic Console ) : ( <>Obtén tu API key en Google AI Studio )}

Modelo de IA

{loading ? (
Cargando modelos...
) : filteredModels.length === 0 ? (
No hay modelos disponibles para {formData.provider}
) : (
{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 ( ); })}
)}
); } 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 (

Mis API Tokens

Genera tokens para acceder a la API sin necesidad de login

{/* Modal de Token Creado */} {createdToken && (

Token Creado Exitosamente

⚠️ Guarda este token ahora. No podrás verlo de nuevo.

{createdToken}

Ejemplo de uso:

curl -H "Authorization: Bearer {createdToken}" http://tu-api.com/api/inspections
)} {/* Formulario de Crear Token */} {showCreateForm && (

Generar Nuevo Token

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" />

Te ayuda a identificar para qué usas este token

)} {/* Lista de Tokens */} {loading ? (
Cargando tokens...
) : tokens.length === 0 ? (
🔑

No tienes tokens API creados

Genera uno para acceder a la API sin login

) : (
{tokens.map((token) => (
🔑

{token.description || 'Token sin descripción'}

{token.is_active ? 'Activo' : 'Revocado'}
Creado:{' '} {new Date(token.created_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{token.last_used_at && (
Último uso:{' '} {new Date(token.last_used_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
)} {!token.last_used_at && (
⚠️ Nunca usado
)}
{token.is_active && ( )}
))}
)} {/* Información de Ayuda */}
ℹ️

¿Cómo usar los tokens API?

Los tokens API te permiten acceder a todos los endpoints sin necesidad de hacer login. Son perfectos para integraciones, scripts automatizados o aplicaciones externas.

Incluye el token en el header Authorization de tus requests:

Authorization: Bearer AYUTEC_tu_token_aqui
) } 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 (
{/* Header */}

Gestionar Preguntas

{checklist.name}

{/* Content */}

Total de preguntas: {questions.length} | Puntuación máxima: {questions.reduce((sum, q) => sum + (q.points || 0), 0)}

{/* Create/Edit Form */} {(showCreateForm || editingQuestion) && (

{editingQuestion ? '✏️ Editar Pregunta' : '➕ Nueva Pregunta'}

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" />
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" />
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 />
{/* Configuración del Tipo de Pregunta */}

📝 Configuración de la Pregunta

{ setFormData({ ...formData, type: config.type, options: config }) }} maxPoints={formData.points} />
{/* Subpreguntas y Preguntas Condicionales - Anidadas hasta 5 niveles */}

📋 Subpreguntas y Preguntas Condicionales (hasta 5 niveles)

{/* Selector de pregunta padre - SIEMPRE disponible */}

Si seleccionas una pregunta padre, esta pregunta se mostrará como subpregunta debajo de ella

{/* 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 (

✓ Esta subpregunta aparecerá SIEMPRE debajo de la pregunta padre seleccionada

) } return (

{formData.show_if_answer ? '⚡ Se mostrará SOLO cuando la respuesta del padre coincida' : '✓ Se mostrará SIEMPRE debajo de la pregunta padre'}

) })()} {/* 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 (
= 5 ? 'bg-red-50 border border-red-200' : 'bg-blue-100'}`}>

📊 Profundidad: Nivel {newDepth} de 5 máximo {newDepth >= 5 && ' ⚠️ Máximo alcanzado'}

) })()}
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */} {checklist.ai_mode !== 'off' && (

🤖 Prompt de IA (opcional)