Cambios v1.0.74 Lógica Implementada: Preguntas padre solo se pueden reordenar entre sí Subpreguntas solo se pueden reordenar con otras subpreguntas del mismo padre No se permite arrastrar una pregunta padre dentro de subpreguntas o viceversa Validaciones: ✅ En handleDragOver: Cursor none si intentas arrastrar entre diferentes niveles ✅ En handleDrop: Mensajes de error claros si intentas mezclar niveles ✅ Filtrado inteligente: Solo reordena el grupo correcto de preguntas
5842 lines
239 KiB
JavaScript
5842 lines
239 KiB
JavaScript
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import SignatureCanvas from 'react-signature-canvas'
|
||
import Sidebar from './Sidebar'
|
||
import QuestionTypeEditor from './QuestionTypeEditor'
|
||
import QuestionAnswerInput from './QuestionAnswerInput'
|
||
|
||
function App() {
|
||
const [user, setUser] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
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">
|
||
{!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)
|
||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||
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 ? 'ml-64' : 'ml-16'}`}>
|
||
{/* Header */}
|
||
<header className="bg-gradient-to-r from-indigo-600 to-purple-600 shadow-lg">
|
||
<div className="px-4 sm:px-6 lg:px-8 py-4">
|
||
<div className="flex items-center justify-between">
|
||
{/* Logo y Nombre del Sistema */}
|
||
<div className="flex items-center gap-3">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-[70px] w-[203px] object-contain bg-white rounded-xl shadow-lg" />
|
||
) : (
|
||
<div className="h-[70px] w-[203px] bg-white rounded-xl flex items-center justify-center shadow-lg text-gray-400">Sin logo</div>
|
||
)}
|
||
<div>
|
||
<h1 className="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="flex items-center gap-3 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-lg border border-white/20">
|
||
<span className="text-2xl">
|
||
{activeTab === 'checklists' && '📋'}
|
||
{activeTab === 'inspections' && '🔍'}
|
||
{activeTab === 'users' && '👥'}
|
||
{activeTab === 'reports' && '📊'}
|
||
{activeTab === 'settings' && '⚙️'}
|
||
</span>
|
||
<span className="text-white font-semibold">
|
||
{activeTab === 'checklists' && 'Checklists'}
|
||
{activeTab === 'inspections' && 'Inspecciones'}
|
||
{activeTab === 'users' && 'Usuarios'}
|
||
{activeTab === 'reports' && 'Reportes'}
|
||
{activeTab === 'settings' && 'Configuración'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 p-6">
|
||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden h-full border border-indigo-100">
|
||
<div className="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} />
|
||
) : 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}
|
||
user={user}
|
||
onClose={() => setActiveInspection(null)}
|
||
onComplete={() => {
|
||
setActiveInspection(null)
|
||
loadData()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingsTab({ user }) {
|
||
// Estado para el logo
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
const [logoUploading, setLogoUploading] = useState(false);
|
||
const [aiConfig, setAiConfig] = useState(null);
|
||
const [availableModels, setAvailableModels] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [formData, setFormData] = useState({
|
||
provider: 'openai',
|
||
api_key: '',
|
||
model_name: 'gpt-4o'
|
||
});
|
||
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
}, []);
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
|
||
// Cargar modelos disponibles
|
||
const modelsRes = await fetch(`${API_URL}/api/ai/models`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (modelsRes.ok) {
|
||
const models = await modelsRes.json();
|
||
setAvailableModels(models);
|
||
}
|
||
|
||
// Cargar configuración actual
|
||
const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
|
||
if (configRes.ok) {
|
||
const config = await configRes.json();
|
||
setAiConfig(config);
|
||
setFormData({
|
||
provider: config.provider || 'openai',
|
||
api_key: config.api_key || '',
|
||
model_name: config.model_name || 'gpt-4o'
|
||
});
|
||
} else if (configRes.status === 404) {
|
||
// No hay configuración guardada, usar valores por defecto
|
||
console.log('No hay configuración de IA guardada');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading settings:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleLogoUpload = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
setLogoUploading(true);
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const formDataLogo = new FormData();
|
||
formDataLogo.append('file', file);
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formDataLogo
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
alert('Logo actualizado correctamente');
|
||
} else {
|
||
alert('Error al subir el logo');
|
||
}
|
||
} catch {
|
||
alert('Error al subir el logo');
|
||
} finally {
|
||
setLogoUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const response = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
});
|
||
if (response.ok) {
|
||
alert('Configuración guardada correctamente');
|
||
loadSettings();
|
||
} else {
|
||
alert('Error al guardar configuración');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
alert('Error al guardar configuración');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const filteredModels = availableModels.filter(m => m.provider === formData.provider);
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<form onSubmit={handleSave}>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Logo del Sistema</h2>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-[70px] w-[203px] object-contain rounded-xl border shadow" />
|
||
) : (
|
||
<div className="h-[70px] w-[203px] bg-gray-200 rounded-xl flex items-center justify-center text-gray-400">Sin logo</div>
|
||
)}
|
||
<div>
|
||
<input type="file" accept="image/*" onChange={handleLogoUpload} disabled={logoUploading} />
|
||
{logoUploading && <span className="ml-2 text-blue-600">Subiendo...</span>}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-2">El logo se mostrará en el login y en la página principal.</p>
|
||
</div>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Configuración de IA</h2>
|
||
<p className="text-sm text-gray-600 mt-1">Configura el proveedor y modelo de IA para análisis de imágenes</p>
|
||
<div className="flex gap-4 mt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'openai', model_name: 'gpt-4o' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'openai' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">🤖</div>
|
||
<div className="font-semibold">OpenAI</div>
|
||
<div className="text-xs text-gray-600 mt-1">GPT-4, GPT-4 Vision</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'gemini', model_name: 'gemini-2.5-pro' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'gemini' ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">✨</div>
|
||
<div className="font-semibold">Google Gemini</div>
|
||
<div className="text-xs text-gray-600 mt-1">Gemini Pro, Flash</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">API Key</h3>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{formData.provider === 'openai' ? 'OpenAI API Key' : 'Google AI API Key'}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData.api_key}
|
||
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'}
|
||
required
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
{formData.provider === 'openai' ? (
|
||
<>Obtén tu API key en <a href="https://platform.openai.com/api-keys" target="_blank" className="text-blue-600 hover:underline">OpenAI Platform</a></>
|
||
) : (
|
||
<>Obtén tu API key en <a href="https://makersuite.google.com/app/apikey" target="_blank" className="text-blue-600 hover:underline">Google AI Studio</a></>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modelo de IA</h3>
|
||
{loading ? (
|
||
<div className="text-gray-500">Cargando modelos...</div>
|
||
) : filteredModels.length === 0 ? (
|
||
<div className="text-gray-500">No hay modelos disponibles para {formData.provider}</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{filteredModels.map((model) => (
|
||
<label key={model.id} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${formData.model_name === model.id ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
|
||
<input
|
||
type="radio"
|
||
name="model_name"
|
||
value={model.id}
|
||
checked={formData.model_name === model.id}
|
||
onChange={() => setFormData({ ...formData, model_name: model.id })}
|
||
className="form-radio text-indigo-600"
|
||
/>
|
||
<div className="flex-1">
|
||
<div className="font-semibold text-gray-900">{model.name}</div>
|
||
<div className="text-xs text-gray-500 mt-1">{model.description}</div>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg disabled:opacity-50 disabled:transform-none"
|
||
disabled={saving}
|
||
>
|
||
{saving ? 'Guardando...' : 'Guardar Configuración'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function APITokensTab({ user }) {
|
||
const [tokens, setTokens] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [newTokenDescription, setNewTokenDescription] = useState('')
|
||
const [createdToken, setCreatedToken] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadTokens()
|
||
}, [])
|
||
|
||
const loadTokens = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setTokens(data)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading tokens:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateToken = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ description: newTokenDescription || null }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCreatedToken(data.token)
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al crear token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear token')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleRevokeToken = async (tokenId) => {
|
||
if (!confirm('¿Estás seguro de revocar este token? Esta acción no se puede deshacer.')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens/${tokenId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
alert('Token revocado correctamente')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al revocar token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al revocar token')
|
||
}
|
||
}
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text)
|
||
alert('Token copiado al portapapeles')
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<div className="mb-6 flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-gray-900">Mis API Tokens</h2>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
Genera tokens para acceder a la API sin necesidad de login
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Generar Nuevo Token
|
||
</button>
|
||
</div>
|
||
|
||
{/* Modal de Token Creado */}
|
||
{createdToken && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-bold text-gray-900">Token Creado Exitosamente</h3>
|
||
<p className="text-sm text-yellow-600 mt-2">
|
||
⚠️ Guarda este token ahora. No podrás verlo de nuevo.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-4 mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 text-sm font-mono break-all">{createdToken}</code>
|
||
<button
|
||
onClick={() => copyToClipboard(createdToken)}
|
||
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition flex-shrink-0"
|
||
>
|
||
📋 Copiar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||
<p className="text-sm text-blue-900 font-semibold mb-2">Ejemplo de uso:</p>
|
||
<code className="text-xs text-blue-800 block">
|
||
curl -H "Authorization: Bearer {createdToken}" http://tu-api.com/api/inspections
|
||
</code>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCreatedToken(null)}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Formulario de Crear Token */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||
<h3 className="text-lg font-bold text-gray-900 mb-4">Generar Nuevo Token</h3>
|
||
|
||
<form onSubmit={handleCreateToken}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción (opcional)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newTokenDescription}
|
||
onChange={(e) => setNewTokenDescription(e.target.value)}
|
||
placeholder="ej: Integración con sistema X"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Te ayuda a identificar para qué usas este token
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={creating}
|
||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||
>
|
||
{creating ? 'Generando...' : 'Generar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Lista de Tokens */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando tokens...</div>
|
||
</div>
|
||
) : tokens.length === 0 ? (
|
||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||
<div className="text-4xl mb-3">🔑</div>
|
||
<p className="text-gray-600 mb-2">No tienes tokens API creados</p>
|
||
<p className="text-sm text-gray-500">Genera uno para acceder a la API sin login</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{tokens.map((token) => (
|
||
<div
|
||
key={token.id}
|
||
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
||
>
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-lg">🔑</span>
|
||
<h4 className="font-semibold text-gray-900">
|
||
{token.description || 'Token sin descripción'}
|
||
</h4>
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||
token.is_active
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-red-100 text-red-800'
|
||
}`}>
|
||
{token.is_active ? 'Activo' : 'Revocado'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
<div>
|
||
<span className="text-gray-500">Creado:</span>{' '}
|
||
{new Date(token.created_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
{token.last_used_at && (
|
||
<div>
|
||
<span className="text-gray-500">Último uso:</span>{' '}
|
||
{new Date(token.last_used_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
)}
|
||
{!token.last_used_at && (
|
||
<div className="text-yellow-600">
|
||
⚠️ Nunca usado
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{token.is_active && (
|
||
<button
|
||
onClick={() => handleRevokeToken(token.id)}
|
||
className="ml-4 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||
>
|
||
Revocar
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Información de Ayuda */}
|
||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<span className="text-blue-600 text-xl">ℹ️</span>
|
||
<div className="flex-1 text-sm text-blue-900">
|
||
<p className="font-semibold mb-2">¿Cómo usar los tokens API?</p>
|
||
<p className="mb-2">
|
||
Los tokens API te permiten acceder a todos los endpoints sin necesidad de hacer login.
|
||
Son perfectos para integraciones, scripts automatizados o aplicaciones externas.
|
||
</p>
|
||
<p className="mb-2">
|
||
Incluye el token en el header <code className="bg-blue-100 px-1 py-0.5 rounded">Authorization</code> de tus requests:
|
||
</p>
|
||
<code className="block bg-blue-100 p-2 rounded text-xs mt-2">
|
||
Authorization: Bearer AYUTEC_tu_token_aqui
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function QuestionsManagerModal({ checklist, onClose }) {
|
||
const [questions, setQuestions] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [editingQuestion, setEditingQuestion] = useState(null)
|
||
const [viewingAudit, setViewingAudit] = useState(null)
|
||
const [auditHistory, setAuditHistory] = useState([])
|
||
const [loadingAudit, setLoadingAudit] = useState(false)
|
||
const [draggedQuestion, setDraggedQuestion] = useState(null)
|
||
const [dragOverQuestion, setDragOverQuestion] = useState(null)
|
||
const [formData, setFormData] = useState({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadQuestions()
|
||
}, [])
|
||
|
||
const loadQuestions = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setQuestions(data.questions || [])
|
||
}
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading questions:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadAuditHistory = async (questionId) => {
|
||
setLoadingAudit(true)
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${questionId}/audit`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAuditHistory(data)
|
||
setViewingAudit(questionId)
|
||
} else {
|
||
alert('Error al cargar historial')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading audit history:', error)
|
||
alert('Error al cargar historial')
|
||
} finally {
|
||
setLoadingAudit(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateQuestion = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
...formData,
|
||
checklist_id: checklist.id
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateForm(false)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al crear pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear pregunta')
|
||
}
|
||
}
|
||
|
||
const handleEditQuestion = (question) => {
|
||
setEditingQuestion(question)
|
||
setShowCreateForm(false)
|
||
setFormData({
|
||
section: question.section || '',
|
||
text: question.text,
|
||
type: question.type,
|
||
points: question.points || 1,
|
||
options: question.options || {
|
||
type: question.type,
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: question.allow_photos ?? true,
|
||
max_photos: question.max_photos || 3,
|
||
requires_comment_on_fail: question.requires_comment_on_fail || false,
|
||
send_notification: question.send_notification || false,
|
||
parent_question_id: question.parent_question_id || null,
|
||
show_if_answer: question.show_if_answer || '',
|
||
ai_prompt: question.ai_prompt || ''
|
||
})
|
||
}
|
||
|
||
const handleUpdateQuestion = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${editingQuestion.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
checklist_id: checklist.id,
|
||
section: formData.section,
|
||
text: formData.text,
|
||
type: formData.type,
|
||
points: parseInt(formData.points),
|
||
options: formData.options,
|
||
allow_photos: formData.allow_photos,
|
||
max_photos: parseInt(formData.max_photos),
|
||
requires_comment_on_fail: formData.requires_comment_on_fail,
|
||
send_notification: formData.send_notification,
|
||
parent_question_id: formData.parent_question_id || null,
|
||
show_if_answer: formData.show_if_answer || null,
|
||
ai_prompt: formData.ai_prompt || null
|
||
})
|
||
})
|
||
|
||
if (response.ok) {
|
||
setEditingQuestion(null)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al actualizar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar pregunta')
|
||
}
|
||
}
|
||
|
||
const handleDeleteQuestion = async (questionId) => {
|
||
if (!confirm('¿Estás seguro de eliminar esta pregunta?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${questionId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
alert('✅ Pregunta eliminada exitosamente')
|
||
} else {
|
||
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }))
|
||
|
||
if (response.status === 400) {
|
||
// Error de validación (pregunta con respuestas o subpreguntas)
|
||
alert(`⚠️ ${errorData.detail}`)
|
||
} else {
|
||
alert('❌ Error al eliminar pregunta')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('❌ Error de conexión al eliminar pregunta')
|
||
}
|
||
}
|
||
|
||
const moveQuestion = async (questionId, direction) => {
|
||
const questionsList = Object.values(questionsBySection).flat()
|
||
const currentIndex = questionsList.findIndex(q => q.id === questionId)
|
||
|
||
if (currentIndex === -1) return
|
||
|
||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
||
|
||
if (newIndex < 0 || newIndex >= questionsList.length) return
|
||
|
||
// Crear nueva lista con el orden actualizado
|
||
const newList = [...questionsList]
|
||
const [movedQuestion] = newList.splice(currentIndex, 1)
|
||
newList.splice(newIndex, 0, movedQuestion)
|
||
|
||
// Preparar datos para el backend
|
||
const reorderData = newList.map((q, index) => ({
|
||
question_id: q.id,
|
||
new_order: index
|
||
}))
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}/questions/reorder`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(reorderData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
}
|
||
|
||
// Drag & Drop handlers
|
||
const handleDragStart = (e, question) => {
|
||
setDraggedQuestion(question)
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.currentTarget.style.opacity = '0.5'
|
||
}
|
||
|
||
const handleDragEnd = (e) => {
|
||
e.currentTarget.style.opacity = '1'
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
const handleDragLeave = (e) => {
|
||
setDragOverQuestion(null)
|
||
}
|
||
|
||
const handleDrop = async (e, targetQuestion) => {
|
||
e.preventDefault()
|
||
|
||
if (!draggedQuestion || draggedQuestion.id === targetQuestion.id) {
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Validar que sean del mismo nivel
|
||
const draggedIsChild = !!draggedQuestion.parent_question_id
|
||
const targetIsChild = !!targetQuestion.parent_question_id
|
||
|
||
if (draggedIsChild !== targetIsChild) {
|
||
alert('⚠️ Solo puedes reordenar preguntas del mismo nivel')
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Si son hijos, validar que sean del mismo padre
|
||
if (draggedIsChild && draggedQuestion.parent_question_id !== targetQuestion.parent_question_id) {
|
||
alert('⚠️ Solo puedes reordenar subpreguntas del mismo padre')
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
return
|
||
}
|
||
|
||
// Filtrar solo las preguntas del mismo nivel/grupo
|
||
let questionsList
|
||
if (draggedIsChild) {
|
||
// Solo subpreguntas del mismo padre
|
||
questionsList = questions.filter(q =>
|
||
q.parent_question_id === draggedQuestion.parent_question_id
|
||
)
|
||
} else {
|
||
// Solo preguntas padre (sin parent_question_id)
|
||
questionsList = questions.filter(q => !q.parent_question_id)
|
||
}
|
||
|
||
const draggedIndex = questionsList.findIndex(q => q.id === draggedQuestion.id)
|
||
const targetIndex = questionsList.findIndex(q => q.id === targetQuestion.id)
|
||
|
||
// Crear nueva lista con el orden actualizado
|
||
const newList = [...questionsList]
|
||
const [movedQuestion] = newList.splice(draggedIndex, 1)
|
||
newList.splice(targetIndex, 0, movedQuestion)
|
||
|
||
// Preparar datos para el backend (solo las preguntas afectadas)
|
||
const reorderData = newList.map((q, index) => ({
|
||
question_id: q.id,
|
||
new_order: index
|
||
}))
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}/questions/reorder`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(reorderData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al reordenar pregunta')
|
||
}
|
||
|
||
setDraggedQuestion(null)
|
||
setDragOverQuestion(null)
|
||
}
|
||
|
||
const questionsBySection = questions.reduce((acc, q) => {
|
||
const section = q.section || 'Sin sección'
|
||
if (!acc[section]) acc[section] = []
|
||
acc[section].push(q)
|
||
return acc
|
||
}, {})
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-purple-600 text-white p-6 rounded-t-lg">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Gestionar Preguntas</h2>
|
||
<p className="mt-1 opacity-90">{checklist.name}</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<div>
|
||
<p className="text-sm text-gray-600">
|
||
Total de preguntas: <strong>{questions.length}</strong> |
|
||
Puntuación máxima: <strong>{questions.reduce((sum, q) => sum + (q.points || 0), 0)}</strong>
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateForm(!showCreateForm)
|
||
setEditingQuestion(null)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'boolean',
|
||
points: 1,
|
||
options: {
|
||
type: 'boolean',
|
||
choices: [
|
||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||
]
|
||
},
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
}}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
{showCreateForm || editingQuestion ? 'Cancelar' : '+ Nueva Pregunta'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Create/Edit Form */}
|
||
{(showCreateForm || editingQuestion) && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||
<h3 className="text-lg font-semibold text-purple-900 mb-4">
|
||
{editingQuestion ? '✏️ Editar Pregunta' : '➕ Nueva Pregunta'}
|
||
</h3>
|
||
<form onSubmit={editingQuestion ? handleUpdateQuestion : handleCreateQuestion} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Sección
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.section}
|
||
onChange={(e) => setFormData({ ...formData, section: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Motor, Frenos, Documentación"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Puntos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={formData.points}
|
||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) || 1 })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Texto de la pregunta *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.text}
|
||
onChange={(e) => setFormData({ ...formData, text: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Estado de las pastillas de freno"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Configuración del Tipo de Pregunta */}
|
||
<div className="bg-white border-2 border-purple-300 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-purple-900 mb-3">📝 Configuración de la Pregunta</h4>
|
||
<QuestionTypeEditor
|
||
value={formData.options || null}
|
||
onChange={(config) => {
|
||
setFormData({
|
||
...formData,
|
||
type: config.type,
|
||
options: config
|
||
})
|
||
}}
|
||
maxPoints={formData.points}
|
||
/>
|
||
</div>
|
||
|
||
{/* Pregunta Condicional - Subpreguntas Anidadas hasta 5 niveles */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||
⚡ Pregunta Condicional - Subpreguntas Anidadas (hasta 5 niveles)
|
||
</h4>
|
||
{/* Solo permitir condicionales para boolean y single_choice */}
|
||
{(() => {
|
||
const currentType = formData.options?.type || formData.type
|
||
const allowConditional = ['boolean', 'single_choice'].includes(currentType)
|
||
|
||
if (!allowConditional) {
|
||
return (
|
||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||
<p className="text-sm text-gray-600">
|
||
ℹ️ Las preguntas condicionales solo están disponibles para tipos <strong>Boolean</strong> y <strong>Opción Única</strong>
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Pregunta padre
|
||
</label>
|
||
<select
|
||
value={formData.parent_question_id || ''}
|
||
onChange={(e) => setFormData({
|
||
...formData,
|
||
parent_question_id: e.target.value ? parseInt(e.target.value) : null,
|
||
show_if_answer: '' // Reset al cambiar padre
|
||
})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
>
|
||
<option value="">Ninguna (pregunta principal)</option>
|
||
{questions
|
||
.filter(q => {
|
||
// Permitir cualquier pregunta que no sea esta misma
|
||
// y que tenga depth_level < 5 (para no exceder límite)
|
||
const depth = q.depth_level || 0
|
||
return depth < 5
|
||
})
|
||
.map(q => {
|
||
const depth = q.depth_level || 0
|
||
const indent = ' '.repeat(depth)
|
||
const levelLabel = depth > 0 ? ` [Nivel ${depth}]` : ''
|
||
return (
|
||
<option key={q.id} value={q.id}>
|
||
{indent}#{q.id} - {q.text.substring(0, 40)}{q.text.length > 40 ? '...' : ''}{levelLabel}
|
||
</option>
|
||
)
|
||
})
|
||
}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Esta pregunta aparecerá solo si se cumple la condición de la pregunta padre
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Mostrar si la respuesta es:
|
||
</label>
|
||
<select
|
||
value={formData.show_if_answer}
|
||
onChange={(e) => setFormData({ ...formData, show_if_answer: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
disabled={!formData.parent_question_id}
|
||
>
|
||
<option value="">Seleccione...</option>
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
if (!parentQ) return null
|
||
|
||
// Leer opciones del nuevo formato
|
||
const config = parentQ.options || {}
|
||
const parentType = config.type || parentQ.type
|
||
|
||
// Para boolean o single_choice, mostrar las opciones configuradas
|
||
if ((parentType === 'boolean' || parentType === 'single_choice') && config.choices) {
|
||
return config.choices.map((choice, idx) => (
|
||
<option key={idx} value={choice.value}>
|
||
{choice.label}
|
||
</option>
|
||
))
|
||
}
|
||
|
||
// Compatibilidad con tipos antiguos
|
||
if (parentType === 'pass_fail') {
|
||
return [
|
||
<option key="pass" value="pass">✓ Pasa</option>,
|
||
<option key="fail" value="fail">✗ Falla</option>
|
||
]
|
||
} else if (parentType === 'good_bad') {
|
||
return [
|
||
<option key="good" value="good">✓ Bueno</option>,
|
||
<option key="bad" value="bad">✗ Malo</option>
|
||
]
|
||
}
|
||
|
||
return <option disabled>Tipo de pregunta no compatible</option>
|
||
})()}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
La pregunta solo se mostrará con esta respuesta específica
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Indicador de profundidad */}
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
const parentDepth = parentQ?.depth_level || 0
|
||
const newDepth = parentDepth + 1
|
||
|
||
return (
|
||
<div className={`mt-3 p-2 rounded ${newDepth >= 5 ? 'bg-red-50 border border-red-200' : 'bg-blue-100'}`}>
|
||
<p className="text-xs">
|
||
📊 <strong>Profundidad:</strong> Nivel {newDepth} de 5 máximo
|
||
{newDepth >= 5 && ' ⚠️ Máximo alcanzado'}
|
||
</p>
|
||
</div>
|
||
)
|
||
})()}
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-purple-900 mb-3">🤖 Prompt de IA (opcional)</h4>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Prompt personalizado para análisis de fotos
|
||
</label>
|
||
<textarea
|
||
value={formData.ai_prompt}
|
||
onChange={(e) => setFormData({ ...formData, ai_prompt: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
rows="3"
|
||
placeholder="Ej: Analiza si las luces delanteras están encendidas y funcionando correctamente. Verifica que ambas luces tengan brillo uniforme y no presenten daños visibles."
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Este prompt guiará a la IA para analizar las fotos específicamente para esta pregunta. Si la foto no corresponde al contexto, la IA sugerirá cambiarla.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Puntos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={formData.points}
|
||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.allow_photos}
|
||
onChange={(e) => setFormData({ ...formData, allow_photos: e.target.checked })}
|
||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Permitir fotos
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Máx. fotos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
value={formData.max_photos}
|
||
onChange={(e) => setFormData({ ...formData, max_photos: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
disabled={!formData.allow_photos}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.send_notification}
|
||
onChange={(e) => setFormData({ ...formData, send_notification: e.target.checked })}
|
||
className="w-4 h-4 text-yellow-600 border-gray-300 rounded focus:ring-yellow-500"
|
||
/>
|
||
<label className="text-sm font-medium text-gray-700">
|
||
🔔 Enviar notificación cuando se responda esta pregunta
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mt-2 ml-6">
|
||
Si activas esta opción, se enviará una notificación automática al administrador cada vez que un mecánico responda esta pregunta.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
{editingQuestion ? 'Actualizar Pregunta' : 'Crear Pregunta'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Questions List */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando preguntas...</div>
|
||
</div>
|
||
) : questions.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No hay preguntas en este checklist</p>
|
||
<p className="text-sm text-gray-400 mt-2">Crea la primera pregunta para comenzar</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{Object.entries(questionsBySection).map(([section, sectionQuestions]) => (
|
||
<div key={section} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3">
|
||
<h3 className="font-semibold text-gray-900">{section}</h3>
|
||
<p className="text-sm text-gray-600">
|
||
{sectionQuestions.length} preguntas | {sectionQuestions.reduce((sum, q) => sum + (q.points || 0), 0)} puntos
|
||
</p>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{sectionQuestions.map((question) => {
|
||
const isSubQuestion = question.parent_question_id
|
||
const parentQuestion = isSubQuestion ? questions.find(q => q.id === question.parent_question_id) : null
|
||
|
||
return (
|
||
<div
|
||
key={question.id}
|
||
draggable={true}
|
||
onDragStart={(e) => handleDragStart(e, question)}
|
||
onDragEnd={handleDragEnd}
|
||
onDragOver={(e) => handleDragOver(e, question)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => handleDrop(e, question)}
|
||
className={`p-4 hover:bg-gray-50 flex justify-between items-start cursor-move transition-all ${
|
||
isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : ''
|
||
} ${
|
||
dragOverQuestion?.id === question.id ? 'border-t-4 border-indigo-500' : ''
|
||
}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
{isSubQuestion && (
|
||
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
|
||
⚡ Condicional
|
||
</span>
|
||
)}
|
||
<p className="text-gray-900">{question.text}</p>
|
||
</div>
|
||
{isSubQuestion && parentQuestion && (
|
||
<p className="text-xs text-blue-600 mt-1">
|
||
→ Aparece si <strong>#{question.parent_question_id}</strong> es <strong>{question.show_if_answer}</strong>
|
||
</p>
|
||
)}
|
||
<div className="flex gap-4 mt-2 text-sm text-gray-600">
|
||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{question.type}
|
||
</span>
|
||
<span>{question.points} pts</span>
|
||
{question.allow_photos && (
|
||
<span>📷 Máx {question.max_photos} fotos</span>
|
||
)}
|
||
{question.send_notification && (
|
||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
||
🔔 Notificación
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="ml-4 flex gap-2 items-center">
|
||
{/* Indicador de drag */}
|
||
<div className="text-gray-400 hover:text-gray-600 cursor-move px-2" title="Arrastra para reordenar">
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z"></path>
|
||
</svg>
|
||
</div>
|
||
<div className="h-8 w-px bg-gray-300"></div>
|
||
<button
|
||
onClick={() => loadAuditHistory(question.id)}
|
||
className="text-gray-600 hover:text-gray-700 text-sm"
|
||
title="Ver historial de cambios"
|
||
>
|
||
📜 Historial
|
||
</button>
|
||
<button
|
||
onClick={() => handleEditQuestion(question)}
|
||
className="text-blue-600 hover:text-blue-700 text-sm"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteQuestion(question.id)}
|
||
className="text-red-600 hover:text-red-700 text-sm"
|
||
>
|
||
🗑️ Eliminar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Audit History Modal */}
|
||
{viewingAudit && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-gray-700 text-white p-4 rounded-t-lg flex justify-between items-center">
|
||
<h3 className="text-lg font-bold">📜 Historial de Cambios - Pregunta #{viewingAudit}</h3>
|
||
<button
|
||
onClick={() => {
|
||
setViewingAudit(null)
|
||
setAuditHistory([])
|
||
}}
|
||
className="text-white hover:bg-gray-600 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loadingAudit ? (
|
||
<div className="text-center py-8 text-gray-500">Cargando historial...</div>
|
||
) : auditHistory.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500">No hay cambios registrados</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{auditHistory.map((log) => (
|
||
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||
log.action === 'created' ? 'bg-green-100 text-green-800' :
|
||
log.action === 'updated' ? 'bg-blue-100 text-blue-800' :
|
||
'bg-red-100 text-red-800'
|
||
}`}>
|
||
{log.action === 'created' ? '➕ Creado' :
|
||
log.action === 'updated' ? '✏️ Modificado' :
|
||
'🗑️ Eliminado'}
|
||
</span>
|
||
{log.field_name && (
|
||
<span className="text-sm text-gray-600">
|
||
Campo: <strong>{log.field_name}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="text-xs text-gray-500">
|
||
{new Date(log.created_at).toLocaleString('es-PY', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
{log.field_name && (
|
||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||
<div className="bg-red-50 border border-red-200 rounded p-2 text-sm">
|
||
{log.old_value || '-'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||
<div className="bg-green-50 border border-green-200 rounded p-2 text-sm">
|
||
{log.new_value || '-'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!log.field_name && (log.old_value || log.new_value) && (
|
||
<div className="mt-2 text-sm text-gray-700">
|
||
{log.old_value || log.new_value}
|
||
</div>
|
||
)}
|
||
|
||
{log.comment && (
|
||
<div className="mt-2 text-sm text-gray-600 italic">
|
||
💬 {log.comment}
|
||
</div>
|
||
)}
|
||
|
||
{log.user && (
|
||
<div className="mt-2 text-xs text-gray-500">
|
||
Por: {log.user.full_name || log.user.username}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={() => {
|
||
setViewingAudit(null)
|
||
setAuditHistory([])
|
||
}}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection }) {
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [showQuestionsModal, setShowQuestionsModal] = useState(false)
|
||
const [showEditPermissionsModal, setShowEditPermissionsModal] = useState(false)
|
||
const [showEditChecklistModal, setShowEditChecklistModal] = useState(false)
|
||
const [showLogoModal, setShowLogoModal] = useState(false)
|
||
const [selectedChecklist, setSelectedChecklist] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
const [updating, setUpdating] = useState(false)
|
||
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||
const [mechanics, setMechanics] = useState([])
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [aiModeFilter, setAiModeFilter] = useState('all') // all, off, optional, required
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const itemsPerPage = 10
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
const [editChecklistData, setEditChecklistData] = useState({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true
|
||
})
|
||
const [editPermissionsData, setEditPermissionsData] = useState({
|
||
mechanic_ids: []
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadMechanics()
|
||
}, [])
|
||
|
||
const loadMechanics = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/users`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// Filtrar solo mecánicos activos
|
||
const mechanicUsers = data.filter(u =>
|
||
(u.role === 'mechanic' || u.role === 'mecanico') && u.is_active
|
||
)
|
||
setMechanics(mechanicUsers)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading mechanics:', error)
|
||
}
|
||
}
|
||
|
||
// Filtrar checklists
|
||
const filteredChecklists = checklists.filter(checklist => {
|
||
const matchesSearch =
|
||
checklist.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
checklist.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
checklist.id?.toString().includes(searchTerm)
|
||
|
||
const matchesAiMode =
|
||
aiModeFilter === 'all' || checklist.ai_mode === aiModeFilter
|
||
|
||
return matchesSearch && matchesAiMode
|
||
})
|
||
|
||
// Calcular paginación
|
||
const totalPages = Math.ceil(filteredChecklists.length / itemsPerPage)
|
||
const startIndex = (currentPage - 1) * itemsPerPage
|
||
const endIndex = startIndex + itemsPerPage
|
||
const paginatedChecklists = filteredChecklists.slice(startIndex, endIndex)
|
||
|
||
// Reset a página 1 cuando cambian los filtros
|
||
useEffect(() => {
|
||
setCurrentPage(1)
|
||
}, [searchTerm, aiModeFilter])
|
||
|
||
const handleCreate = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
onChecklistCreated()
|
||
} else {
|
||
alert('Error al crear checklist')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear checklist')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleEditPermissions = async (e) => {
|
||
e.preventDefault()
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(editPermissionsData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
onChecklistCreated() // Reload checklists
|
||
} else {
|
||
alert('Error al actualizar permisos')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar permisos')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleEditChecklist = async (e) => {
|
||
e.preventDefault()
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(editChecklistData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowEditChecklistModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditChecklistData({ name: '', description: '', ai_mode: 'off', scoring_enabled: true })
|
||
onChecklistCreated() // Reload checklists
|
||
} else {
|
||
alert('Error al actualizar checklist')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar checklist')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
const handleUploadLogo = async (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
// Validar que sea imagen
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('Por favor selecciona una imagen válida')
|
||
return
|
||
}
|
||
|
||
setUploadingLogo(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/upload-logo`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated() // Reload checklists
|
||
alert('Logo subido exitosamente')
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al subir el logo')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al subir el logo')
|
||
} finally {
|
||
setUploadingLogo(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteLogo = async () => {
|
||
if (!confirm('¿Estás seguro de eliminar el logo?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/logo`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated() // Reload checklists
|
||
alert('Logo eliminado exitosamente')
|
||
} else {
|
||
alert('Error al eliminar el logo')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al eliminar el logo')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{user.role === 'admin' && (
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Crear Checklist
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Buscador y Filtros */}
|
||
{checklists.length > 0 && (
|
||
<div className="mb-6 space-y-4">
|
||
<div className="flex gap-4 flex-wrap">
|
||
{/* Buscador */}
|
||
<div className="flex-1 min-w-[300px]">
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar por nombre, descripción o ID..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
{/* Filtro de Modo IA */}
|
||
<select
|
||
value={aiModeFilter}
|
||
onChange={(e) => setAiModeFilter(e.target.value)}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="all">Todos los modos IA</option>
|
||
<option value="off">Sin IA</option>
|
||
<option value="optional">IA Opcional</option>
|
||
<option value="required">IA Requerida</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Contador de resultados */}
|
||
<div className="text-sm text-gray-600">
|
||
Mostrando {startIndex + 1}-{Math.min(endIndex, filteredChecklists.length)} de {filteredChecklists.length} checklists
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{checklists.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
{user.role === 'admin' ? (
|
||
<>
|
||
<p className="text-gray-500">No hay checklists activos</p>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Crear tu primer checklist
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="text-4xl mb-3">🔒</div>
|
||
<p className="text-gray-700 font-semibold">No tienes checklists disponibles</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
Contacta con el administrador para que te asigne permisos a los checklists que necesites usar.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : filteredChecklists.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No se encontraron checklists con los filtros aplicados</p>
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm('')
|
||
setAiModeFilter('all')
|
||
}}
|
||
className="mt-4 text-blue-600 hover:text-blue-700 underline"
|
||
>
|
||
Limpiar filtros
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{paginatedChecklists.map((checklist) => (
|
||
<div key={checklist.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex justify-between items-start gap-4">
|
||
{/* Logo del Checklist */}
|
||
<div className="flex-shrink-0">
|
||
{checklist.logo_url ? (
|
||
<img
|
||
src={checklist.logo_url}
|
||
alt={`Logo ${checklist.name}`}
|
||
className="w-16 h-16 object-contain rounded-lg border border-gray-200"
|
||
/>
|
||
) : (
|
||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-lg flex items-center justify-center">
|
||
<span className="text-2xl">📋</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-gray-900">{checklist.name}</h3>
|
||
<p className="text-sm text-gray-600 mt-1">{checklist.description}</p>
|
||
<div className="flex gap-4 mt-3 text-sm">
|
||
<span className="text-gray-500">
|
||
Puntuación máxima: <strong>{checklist.max_score}</strong>
|
||
</span>
|
||
<span className="text-gray-500">
|
||
Modo IA: <strong className="capitalize">{checklist.ai_mode}</strong>
|
||
</span>
|
||
</div>
|
||
{/* Mostrar permisos de mecánicos */}
|
||
{user.role === 'admin' && (
|
||
<div className="mt-2">
|
||
{!checklist.allowed_mechanics || checklist.allowed_mechanics.length === 0 ? (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||
🌍 Acceso Global - Todos los mecánicos
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||
🔐 Restringido - {checklist.allowed_mechanics.length} mecánico{checklist.allowed_mechanics.length !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{user.role === 'admin' && (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setEditChecklistData({
|
||
name: checklist.name,
|
||
description: checklist.description || '',
|
||
ai_mode: checklist.ai_mode || 'off',
|
||
scoring_enabled: checklist.scoring_enabled ?? true
|
||
})
|
||
setShowEditChecklistModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-indigo-500 to-purple-500 text-white rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Editar checklist"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setShowLogoModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg hover:from-blue-600 hover:to-cyan-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Gestionar logo"
|
||
>
|
||
🖼️ Logo
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setEditPermissionsData({
|
||
mechanic_ids: checklist.allowed_mechanics || []
|
||
})
|
||
setShowEditPermissionsModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Editar permisos"
|
||
>
|
||
🔐 Permisos
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setShowQuestionsModal(true)
|
||
}}
|
||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Gestionar Preguntas
|
||
</button>
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
</>
|
||
)}
|
||
{(user.role === 'mechanic' || user.role === 'mecanico') && (
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Controles de paginación */}
|
||
{filteredChecklists.length > itemsPerPage && (
|
||
<div className="flex items-center justify-center gap-2 mt-6">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
← Anterior
|
||
</button>
|
||
|
||
<div className="flex gap-1">
|
||
{[...Array(totalPages)].map((_, index) => {
|
||
const page = index + 1
|
||
// Mostrar solo páginas cercanas a la actual
|
||
if (
|
||
page === 1 ||
|
||
page === totalPages ||
|
||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||
) {
|
||
return (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-3 py-2 rounded-lg ${
|
||
currentPage === page
|
||
? 'bg-blue-600 text-white'
|
||
: 'border border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
)
|
||
} else if (page === currentPage - 2 || page === currentPage + 2) {
|
||
return <span key={page} className="px-2 py-2">...</span>
|
||
}
|
||
return null
|
||
})}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Siguiente →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Modal Gestionar Preguntas */}
|
||
{showQuestionsModal && selectedChecklist && (
|
||
<QuestionsManagerModal
|
||
checklist={selectedChecklist}
|
||
onClose={() => {
|
||
setShowQuestionsModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Modal Crear Checklist */}
|
||
{showCreateModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Crear Nuevo Checklist</h2>
|
||
|
||
<form onSubmit={handleCreate} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nombre del Checklist *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Ej: Mantenimiento Preventivo"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción
|
||
</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Descripción del checklist..."
|
||
rows="3"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Modo IA
|
||
</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">🚫 Sin IA - Control manual total</option>
|
||
<option value="assisted">🤝 IA Asistida - Sugerencias en fotos</option>
|
||
<option value="full">🤖 IA Completa - Análisis automático</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>Sin IA:</strong> El mecánico completa manualmente todas las respuestas.
|
||
Sin dependencia de internet o API.
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'assisted' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>IA Asistida:</strong> Cuando se suben fotos, la IA analiza y sugiere
|
||
estado, criticidad y observaciones. El mecánico acepta o modifica.
|
||
<div className="mt-1 text-xs">⚠️ Requiere OPENAI_API_KEY configurada</div>
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'full' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>IA Completa:</strong> El mecánico solo toma fotos y la IA responde
|
||
automáticamente todas las preguntas. Ideal para inspecciones rápidas masivas.
|
||
<div className="mt-1 text-xs">⚠️ Requiere OPENAI_API_KEY configurada</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.scoring_enabled}
|
||
onChange={(e) => setFormData({ ...formData, scoring_enabled: e.target.checked })}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Habilitar sistema de puntuación
|
||
</label>
|
||
</div>
|
||
|
||
{/* Selector de Mecánicos Autorizados */}
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({ ...formData, mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: [...formData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: formData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||
<p className="text-sm text-yellow-800">
|
||
ℹ️ Después de crear el checklist, podrás agregar preguntas desde la API o directamente en la base de datos.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={creating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
disabled={creating}
|
||
>
|
||
{creating ? 'Creando...' : 'Crear Checklist'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Editar Checklist */}
|
||
{showEditChecklistModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">✏️ Editar Checklist</h2>
|
||
|
||
<form onSubmit={handleEditChecklist} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre del Checklist *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editChecklistData.name}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, name: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
placeholder="Ej: Inspección Pre-entrega"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Descripción
|
||
</label>
|
||
<textarea
|
||
value={editChecklistData.description}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, description: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
rows="3"
|
||
placeholder="Descripción del checklist..."
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Modo IA
|
||
</label>
|
||
<select
|
||
value={editChecklistData.ai_mode}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, ai_mode: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="off">❌ Desactivado (Manual)</option>
|
||
<option value="assisted">💡 Asistido (Sugerencias)</option>
|
||
<option value="copilot">🤖 Copiloto (Auto-completar)</option>
|
||
</select>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
{editChecklistData.ai_mode === 'off' && '❌ El mecánico completa todo manualmente'}
|
||
{editChecklistData.ai_mode === 'assisted' && '💡 IA sugiere respuestas, el mecánico confirma'}
|
||
{editChecklistData.ai_mode === 'copilot' && '🤖 IA completa automáticamente, el mecánico revisa'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editChecklistData.scoring_enabled}
|
||
onChange={(e) => setEditChecklistData({ ...editChecklistData, scoring_enabled: e.target.checked })}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Habilitar sistema de puntuación
|
||
</label>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<p className="text-sm text-blue-800">
|
||
ℹ️ Los cambios se aplicarán inmediatamente. Las inspecciones existentes no se verán afectadas.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditChecklistModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditChecklistData({ name: '', description: '', ai_mode: 'off', scoring_enabled: true })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={updating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||
disabled={updating}
|
||
>
|
||
{updating ? 'Guardando...' : 'Guardar Cambios'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Editar Permisos */}
|
||
{showEditPermissionsModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Editar Permisos de Checklist</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{selectedChecklist.name}
|
||
</p>
|
||
|
||
<form onSubmit={handleEditPermissions} className="space-y-4">
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-96 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({
|
||
mechanic_ids: [...editPermissionsData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setEditPermissionsData({
|
||
mechanic_ids: editPermissionsData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<p className="text-sm text-blue-800">
|
||
ℹ️ Los cambios se aplicarán inmediatamente. Los mecánicos que pierdan acceso ya no verán este checklist.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={updating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition disabled:opacity-50"
|
||
disabled={updating}
|
||
>
|
||
{updating ? 'Guardando...' : 'Guardar Permisos'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Gestionar Logo */}
|
||
{showLogoModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-md w-full">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Gestionar Logo</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{selectedChecklist.name}
|
||
</p>
|
||
|
||
{/* Logo actual */}
|
||
<div className="mb-6 flex justify-center">
|
||
{selectedChecklist.logo_url ? (
|
||
<div className="relative">
|
||
<img
|
||
src={selectedChecklist.logo_url}
|
||
alt="Logo actual"
|
||
className="w-32 h-32 object-contain rounded-lg border-2 border-gray-200"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-lg flex items-center justify-center">
|
||
<span className="text-4xl">📋</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Botones de acción */}
|
||
<div className="space-y-3">
|
||
<label className="block">
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleUploadLogo}
|
||
className="hidden"
|
||
disabled={uploadingLogo}
|
||
/>
|
||
<div className="px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-center cursor-pointer">
|
||
{uploadingLogo ? 'Subiendo...' : selectedChecklist.logo_url ? '🔄 Cambiar Logo' : '📤 Subir Logo'}
|
||
</div>
|
||
</label>
|
||
|
||
{selectedChecklist.logo_url && (
|
||
<button
|
||
onClick={handleDeleteLogo}
|
||
className="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||
>
|
||
🗑️ Eliminar Logo
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => {
|
||
setShowLogoModal(false)
|
||
setSelectedChecklist(null)
|
||
}}
|
||
className="w-full px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
|
||
<p className="mt-4 text-xs text-gray-500 text-center">
|
||
💡 Tamaño recomendado: 200x200px o similar. Formatos: JPG, PNG, SVG
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||
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)
|
||
|
||
useEffect(() => {
|
||
const loadInspectionDetails = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Cargar la inspección completa con respuestas y checklist
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log('Inspection detail loaded:', data)
|
||
setInspectionDetail(data)
|
||
} else {
|
||
console.error('Error loading inspection:', response.status)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading inspection details:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
loadInspectionDetails()
|
||
}, [inspection.id, inspection.checklist_id])
|
||
|
||
const getStatusBadge = (status) => {
|
||
const statusConfig = {
|
||
ok: { bg: 'bg-green-100', text: 'text-green-800', label: '✓ OK' },
|
||
critical: { bg: 'bg-red-100', text: 'text-red-800', label: '✗ Crítico' },
|
||
minor: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '⚠ Menor' },
|
||
na: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'N/A' }
|
||
}
|
||
const config = statusConfig[status] || statusConfig.ok
|
||
return (
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${config.bg} ${config.text}`}>
|
||
{config.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const getCategoryIcon = (category) => {
|
||
const icons = {
|
||
'Motor': '🔧',
|
||
'Frenos': '🛑',
|
||
'Suspensión': '⚙️',
|
||
'Neumáticos': '🚗',
|
||
'Electricidad': '⚡',
|
||
'Carrocería': '🚙',
|
||
'Interior': '🪑',
|
||
'Documentación': '📄'
|
||
}
|
||
return icons[category] || '📋'
|
||
}
|
||
|
||
const loadAuditLog = async () => {
|
||
setLoadingAudit(true)
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/audit-log`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAuditLogs(data)
|
||
setShowAuditLog(true)
|
||
} else {
|
||
alert('Error al cargar historial de cambios')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading audit log:', error)
|
||
alert('Error al cargar historial de cambios')
|
||
} finally {
|
||
setLoadingAudit(false)
|
||
}
|
||
}
|
||
|
||
const startEditAnswer = (answer) => {
|
||
setEditingAnswerId(answer.id)
|
||
setEditFormData({
|
||
answer_value: answer.answer_value || '',
|
||
status: answer.status || 'ok',
|
||
comment: answer.comment || '',
|
||
is_flagged: answer.is_flagged || false,
|
||
edit_comment: ''
|
||
})
|
||
}
|
||
|
||
const cancelEdit = () => {
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
}
|
||
|
||
const saveEdit = async (answerId) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/answers/${answerId}/admin-edit`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(editFormData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
// Recargar detalles de inspección
|
||
const inspectionResponse = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (inspectionResponse.ok) {
|
||
const data = await inspectionResponse.json()
|
||
setInspectionDetail(data)
|
||
}
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
alert('Respuesta actualizada correctamente')
|
||
} else {
|
||
alert('Error al actualizar respuesta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving edit:', error)
|
||
alert('Error al guardar cambios')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-blue-600 text-white p-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Inspección #{inspection.id}</h2>
|
||
<p className="mt-1 opacity-90">
|
||
{inspection.vehicle_plate} - {inspection.vehicle_brand} {inspection.vehicle_model}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-blue-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Info Cards */}
|
||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">OR Number</div>
|
||
<div className="text-lg font-bold">{inspection.or_number || 'N/A'}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Kilometraje</div>
|
||
<div className="text-lg font-bold">{inspection.vehicle_km} km</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Score Total</div>
|
||
<div className="text-lg font-bold">{inspection.score}/{inspection.max_score}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Porcentaje</div>
|
||
<div className="text-lg font-bold">{inspection.percentage.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando detalles...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Order Info */}
|
||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||
<h3 className="font-semibold text-gray-900 mb-2">Información del Pedido</h3>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-600">Nº de Pedido:</span>
|
||
<span className="ml-2 font-medium">{inspection.order_number || 'N/A'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">Fecha:</span>
|
||
<span className="ml-2 font-medium">
|
||
{inspection.started_at ? new Date(inspection.started_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}) : 'N/A'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Debug Info */}
|
||
{inspectionDetail && (
|
||
<div className="bg-gray-100 rounded p-2 mb-4 text-xs">
|
||
<strong>Debug:</strong> Respuestas cargadas: {inspectionDetail.answers?.length || 0},
|
||
Preguntas disponibles: {inspectionDetail.checklist?.questions?.length || 0}
|
||
</div>
|
||
)}
|
||
|
||
{/* Answers by Category */}
|
||
{inspectionDetail && inspectionDetail.checklist && inspectionDetail.checklist.questions && (
|
||
<div className="space-y-6">
|
||
{Object.entries(
|
||
inspectionDetail.checklist.questions.reduce((acc, question) => {
|
||
const category = question.category || 'Sin categoría'
|
||
if (!acc[category]) acc[category] = []
|
||
acc[category].push(question)
|
||
return acc
|
||
}, {})
|
||
).map(([category, categoryQuestions]) => (
|
||
<div key={category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3 flex items-center gap-2">
|
||
<span className="text-2xl">{getCategoryIcon(category)}</span>
|
||
<h3 className="font-semibold text-gray-900">{category}</h3>
|
||
<span className="ml-auto text-sm text-gray-600">
|
||
{categoryQuestions.length} preguntas
|
||
</span>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{categoryQuestions.map((question) => {
|
||
const answer = inspectionDetail.answers?.find(a => a.question_id === question.id)
|
||
return (
|
||
<div key={question.id} className="p-4 hover:bg-gray-50">
|
||
<div className="flex justify-between items-start gap-4">
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<p className="text-gray-900 font-medium">{question.text}</p>
|
||
{question.description && (
|
||
<p className="text-sm text-gray-500 mt-1">{question.description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{answer && (
|
||
<div className="mt-3 ml-10 space-y-2">
|
||
{editingAnswerId === answer.id ? (
|
||
// Modo Edición (solo admin)
|
||
<div className="bg-blue-50 border border-blue-300 rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-blue-700 font-semibold">✏️ Editando Respuesta</span>
|
||
</div>
|
||
|
||
{/* Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||
<select
|
||
value={editFormData.status}
|
||
onChange={(e) => setEditFormData({...editFormData, status: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="ok">✓ OK</option>
|
||
<option value="warning">⚠ Advertencia</option>
|
||
<option value="critical">✗ Crítico</option>
|
||
<option value="na">N/A</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Answer Value (si aplica) */}
|
||
{question.type !== 'pass_fail' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Valor de Respuesta</label>
|
||
<input
|
||
type="text"
|
||
value={editFormData.answer_value}
|
||
onChange={(e) => setEditFormData({...editFormData, answer_value: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</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">{answer.answer_value}</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>
|
||
)}
|
||
|
||
{/* 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">
|
||
{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>
|
||
)}
|
||
<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 }) {
|
||
const [selectedInspection, setSelectedInspection] = useState(null)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState('all') // all, completed, draft
|
||
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 === 'draft' && 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="draft">Borradores</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' : 'Borrador'}
|
||
</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}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function InspectionModal({ checklist, 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(null)
|
||
|
||
// Form data for vehicle and client
|
||
const [vehicleData, setVehicleData] = useState({
|
||
vehicle_plate: '',
|
||
vehicle_brand: '',
|
||
vehicle_model: '',
|
||
vehicle_km: '',
|
||
order_number: '',
|
||
or_number: ''
|
||
})
|
||
|
||
// Answers data
|
||
const [answers, setAnswers] = useState({})
|
||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||
const [aiAnalyzing, setAiAnalyzing] = 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 - empty values to force user interaction
|
||
const initialAnswers = {}
|
||
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])
|
||
|
||
// 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 || ''
|
||
|
||
console.log('Creating inspection with data:', vehicleData)
|
||
|
||
// Prepare data for API
|
||
const inspectionData = {
|
||
checklist_id: checklist.id,
|
||
vehicle_plate: vehicleData.vehicle_plate,
|
||
vehicle_brand: vehicleData.vehicle_brand || null,
|
||
vehicle_model: vehicleData.vehicle_model || null,
|
||
vehicle_km: vehicleData.vehicle_km ? parseInt(vehicleData.vehicle_km) : null,
|
||
order_number: vehicleData.order_number || null,
|
||
or_number: vehicleData.or_number || null
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/inspections`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(inspectionData)
|
||
})
|
||
|
||
console.log('Response status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const inspection = await response.json()
|
||
console.log('Inspection created:', inspection)
|
||
setInspectionId(inspection.id)
|
||
|
||
// Only move to step 2 if questions are loaded
|
||
if (questions.length > 0) {
|
||
setStep(2)
|
||
} else {
|
||
alert('Error: El checklist no tiene preguntas configuradas')
|
||
}
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.error('Error response:', errorText)
|
||
alert('Error al crear la inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear la inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Step 2: Auto-save answer when changed (non-blocking)
|
||
const saveAnswer = async (questionId) => {
|
||
if (!inspectionId) return
|
||
|
||
const question = questions.find(q => q.id === questionId)
|
||
const answer = answers[questionId]
|
||
|
||
// Don't save if no value AND no observations AND no photos
|
||
if (!answer?.value && !answer?.observations && (!answer?.photos || answer.photos.length === 0)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Determine status based on answer value and question config
|
||
let status = 'ok'
|
||
const config = question.options || {}
|
||
const questionType = config.type || question.type
|
||
|
||
if (answer?.value) {
|
||
if (questionType === 'boolean' && config.choices) {
|
||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||
status = selectedChoice?.status || 'ok'
|
||
} else if (questionType === 'single_choice' && config.choices) {
|
||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||
status = selectedChoice?.status || 'ok'
|
||
} else if (questionType === 'pass_fail') {
|
||
// Compatibilidad hacia atrás
|
||
status = answer.value === 'pass' ? 'ok' : 'critical'
|
||
} else if (questionType === 'good_bad') {
|
||
// Compatibilidad hacia atrás
|
||
if (answer.value === 'good') status = 'ok'
|
||
else if (answer.value === 'regular') status = 'warning'
|
||
else if (answer.value === 'bad') status = 'critical'
|
||
}
|
||
}
|
||
|
||
// Submit answer
|
||
const answerData = {
|
||
inspection_id: inspectionId,
|
||
question_id: question.id,
|
||
answer_value: answer.value || null,
|
||
status: status,
|
||
comment: answer.observations || null,
|
||
ai_analysis: answer.aiAnalysis || null,
|
||
is_flagged: status === 'critical'
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/answers`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(answerData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const savedAnswer = await response.json()
|
||
|
||
// Upload photos if any
|
||
if (answer.photos.length > 0) {
|
||
for (const photoFile of answer.photos) {
|
||
const formData = new FormData()
|
||
formData.append('file', photoFile)
|
||
|
||
await fetch(`${API_URL}/api/answers/${savedAnswer.id}/upload`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
}
|
||
}
|
||
|
||
// Mark as saved
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: { ...prev[questionId], saved: true }
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving answer:', error)
|
||
}
|
||
}
|
||
|
||
// Navigate between questions freely
|
||
const goToQuestion = (index) => {
|
||
setCurrentQuestionIndex(index)
|
||
}
|
||
|
||
// Validate all questions answered before completing
|
||
const validateAllAnswered = () => {
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const unanswered = visibleQuestions.filter(q => !answers[q.id]?.value)
|
||
return unanswered
|
||
}
|
||
|
||
// Get visible questions based on conditional logic
|
||
const getVisibleQuestions = () => {
|
||
return questions.filter(q => {
|
||
// If no parent, always visible
|
||
if (!q.parent_question_id) return true
|
||
|
||
// Check parent answer
|
||
const parentAnswer = answers[q.parent_question_id]
|
||
if (!parentAnswer) return false
|
||
|
||
// Show if parent answer matches trigger
|
||
return parentAnswer.value === q.show_if_answer
|
||
})
|
||
}
|
||
|
||
// Move to signatures step
|
||
const proceedToSignatures = () => {
|
||
const unanswered = validateAllAnswered()
|
||
if (unanswered.length > 0) {
|
||
alert(`⚠️ Faltan responder ${unanswered.length} pregunta(s). Por favor completa todas las preguntas antes de continuar.`)
|
||
// Go to first unanswered
|
||
const firstIndex = questions.findIndex(q => q.id === unanswered[0].id)
|
||
if (firstIndex >= 0) setCurrentQuestionIndex(firstIndex)
|
||
return
|
||
}
|
||
setStep(3)
|
||
}
|
||
|
||
// Step 3: Submit signatures and complete
|
||
const handleComplete = async () => {
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Get mechanic signature as base64
|
||
const mechanicSig = mechanicSigRef.current ? mechanicSigRef.current.toDataURL() : null
|
||
|
||
// Update inspection with signature
|
||
const updateResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
signature_data: mechanicSig
|
||
})
|
||
})
|
||
|
||
console.log('Update inspection response:', updateResponse.status)
|
||
|
||
// Complete inspection
|
||
const completeResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}/complete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
console.log('Complete inspection response:', completeResponse.status)
|
||
|
||
if (completeResponse.ok) {
|
||
const completedInspection = await completeResponse.json()
|
||
console.log('Inspection completed:', completedInspection)
|
||
alert(`✅ Inspección completada!\n\nPuntuación: ${completedInspection.score}/${completedInspection.max_score} (${completedInspection.percentage.toFixed(1)}%)\nElementos señalados: ${completedInspection.flagged_items_count}`)
|
||
onComplete()
|
||
onClose()
|
||
} else {
|
||
const errorText = await completeResponse.text()
|
||
console.error('Error completing inspection:', errorText)
|
||
alert('Error al completar inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al completar inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handlePhotoChange = async (questionId, files) => {
|
||
const question = questions.find(q => q.id === questionId)
|
||
let filesArray = Array.from(files)
|
||
|
||
// Get existing photos
|
||
const existingPhotos = answers[questionId]?.photos || []
|
||
|
||
// Combine existing and new photos
|
||
const allPhotos = [...existingPhotos, ...filesArray]
|
||
|
||
// Validar límite de fotos
|
||
if (question.max_photos && allPhotos.length > question.max_photos) {
|
||
alert(`⚠️ Solo puedes subir hasta ${question.max_photos} foto${question.max_photos > 1 ? 's' : ''} para esta pregunta`)
|
||
return
|
||
}
|
||
|
||
// Update photos immediately (do NOT auto-analyze)
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: allPhotos
|
||
}
|
||
}))
|
||
}
|
||
|
||
const handleRemovePhoto = (questionId, photoIndex) => {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
photos: prev[questionId].photos.filter((_, index) => index !== photoIndex)
|
||
}
|
||
}))
|
||
}
|
||
|
||
const handleAnalyzePhotos = async (questionId) => {
|
||
const photos = answers[questionId]?.photos || []
|
||
if (photos.length === 0) {
|
||
alert('Primero debes subir al menos una foto')
|
||
return
|
||
}
|
||
|
||
await analyzePhotosWithAI(questionId, photos)
|
||
}
|
||
|
||
const analyzePhotosWithAI = async (questionId, files) => {
|
||
const question = questions.find(q => q.id === questionId)
|
||
if (!question) return
|
||
|
||
console.log('🔍 Iniciando análisis IA para pregunta:', questionId)
|
||
console.log('📸 Archivos a analizar:', files.length)
|
||
console.log('🎯 Modo IA del checklist:', checklist.ai_mode)
|
||
|
||
setAiAnalyzing(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log(`🤖 Analizando ${files.length} foto(s) con IA para pregunta: ${question.text}`)
|
||
|
||
// Use custom AI prompt if available
|
||
if (question.ai_prompt) {
|
||
console.log(`📝 Usando prompt personalizado: ${question.ai_prompt.substring(0, 50)}...`)
|
||
}
|
||
|
||
// Analyze each photo sequentially
|
||
const analyses = []
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
console.log(`📸 Analizando foto ${i + 1} de ${files.length}: ${file.name}`)
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('question_id', question.id.toString())
|
||
|
||
console.log('📤 DATOS ENVIADOS AL BACKEND:')
|
||
console.log(' - question_id:', question.id)
|
||
console.log(' - question.text:', question.text)
|
||
console.log(' - question.ai_prompt:', question.ai_prompt || 'NO TIENE')
|
||
console.log(' - imagen:', i + 1, 'de', files.length)
|
||
|
||
// Include inspection_id for vehicle context
|
||
if (inspectionId) {
|
||
formData.append('inspection_id', inspectionId.toString())
|
||
console.log(' - inspection_id:', inspectionId)
|
||
} else {
|
||
console.log(' - inspection_id: NO ENVIADO')
|
||
}
|
||
|
||
// Include custom prompt if available
|
||
if (question.ai_prompt) {
|
||
formData.append('custom_prompt', question.ai_prompt)
|
||
console.log(' - custom_prompt ENVIADO:', question.ai_prompt)
|
||
} else {
|
||
console.log(' - custom_prompt: NO ENVIADO (pregunta no tiene ai_prompt)')
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/analyze-image`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
|
||
console.log('📥 RESPUESTA DEL BACKEND:')
|
||
console.log(' - Status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const result = await response.json()
|
||
console.log(' - Result completo:', JSON.stringify(result, null, 2))
|
||
|
||
// Check if AI analysis was successful
|
||
if (result.success && result.analysis) {
|
||
analyses.push({ ...result, imageIndex: i + 1, fileName: file.name })
|
||
console.log('✅ Análisis IA exitoso para imagen', i + 1)
|
||
console.log(' - Provider:', result.provider)
|
||
console.log(' - Model:', result.model)
|
||
console.log(' - Status:', result.analysis.status)
|
||
console.log(' - Observations:', result.analysis.observations)
|
||
} else {
|
||
console.warn('⚠️ Error en análisis IA:', result.error || result.message)
|
||
// Show user-friendly error
|
||
if (result.error && result.error.includes('No AI configuration')) {
|
||
alert('⚙️ Por favor configura tu API key en Configuración primero.')
|
||
break // Stop analyzing if no API key
|
||
}
|
||
}
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.warn('⚠️ Error HTTP en análisis IA:', response.status, errorText)
|
||
}
|
||
}
|
||
|
||
if (analyses.length > 0) {
|
||
console.log('✅ Análisis recibidos:', analyses.length)
|
||
|
||
let suggestedAnswer = null
|
||
let observationsText = ''
|
||
let worstStatus = 'ok' // Track the worst status across all images
|
||
|
||
if (analyses.length === 1) {
|
||
// Single image analysis
|
||
const firstResult = analyses[0]
|
||
const analysis = firstResult.analysis
|
||
console.log('📊 Análisis de imagen única:', analysis)
|
||
|
||
// Check if analysis is an object (structured JSON response)
|
||
if (typeof analysis === 'object' && analysis !== null) {
|
||
// Extract structured information
|
||
const status = analysis.status || 'ok'
|
||
const observations = analysis.observations || ''
|
||
const recommendation = analysis.recommendation || ''
|
||
const confidence = analysis.confidence || 0.7
|
||
|
||
// Build observations text
|
||
observationsText = `🤖 Análisis IA (${(confidence * 100).toFixed(0)}% confianza):\n${observations}`
|
||
if (recommendation) {
|
||
observationsText += `\n\n💡 Recomendación: ${recommendation}`
|
||
}
|
||
worstStatus = status
|
||
} else if (typeof analysis === 'string') {
|
||
observationsText = `🤖 Análisis IA:\n${analysis}`
|
||
}
|
||
} else {
|
||
// Multiple images - summarize all analyses
|
||
console.log('📊 Resumen de', analyses.length, 'análisis:')
|
||
observationsText = `🤖 Análisis IA de ${analyses.length} imágenes:\n\n`
|
||
|
||
const statusPriority = { 'critical': 3, 'minor': 2, 'warning': 2, 'ok': 1 }
|
||
let maxPriority = 0
|
||
|
||
analyses.forEach((result, index) => {
|
||
const analysis = result.analysis
|
||
observationsText += `📸 Imagen ${result.imageIndex}:\n`
|
||
|
||
if (typeof analysis === 'object' && analysis !== null) {
|
||
const status = analysis.status || 'ok'
|
||
const observations = analysis.observations || ''
|
||
const confidence = analysis.confidence || 0.7
|
||
|
||
observationsText += ` Estado: ${status.toUpperCase()}`
|
||
observationsText += ` (${(confidence * 100).toFixed(0)}% confianza)\n`
|
||
observationsText += ` ${observations}\n`
|
||
|
||
// Track worst status
|
||
const priority = statusPriority[status] || 1
|
||
if (priority > maxPriority) {
|
||
maxPriority = priority
|
||
worstStatus = status
|
||
}
|
||
} else if (typeof analysis === 'string') {
|
||
observationsText += ` ${analysis}\n`
|
||
}
|
||
observationsText += '\n'
|
||
})
|
||
|
||
// Add overall recommendation
|
||
observationsText += `📋 Resumen General:\n`
|
||
observationsText += ` Estado más crítico detectado: ${worstStatus.toUpperCase()}\n`
|
||
}
|
||
|
||
// Map worst status to answer
|
||
if (question.type === 'pass_fail') {
|
||
if (worstStatus === 'ok') {
|
||
suggestedAnswer = 'pass'
|
||
} else if (worstStatus === 'critical' || worstStatus === 'minor') {
|
||
suggestedAnswer = 'fail'
|
||
}
|
||
} else if (question.type === 'good_bad') {
|
||
if (worstStatus === 'ok') {
|
||
suggestedAnswer = 'good'
|
||
} else if (worstStatus === 'minor' || worstStatus === 'warning') {
|
||
suggestedAnswer = 'regular'
|
||
} else if (worstStatus === 'critical') {
|
||
suggestedAnswer = 'bad'
|
||
}
|
||
}
|
||
|
||
|
||
// In FULL mode, auto-fill the answer
|
||
if (checklist.ai_mode === 'full' && suggestedAnswer) {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
value: suggestedAnswer,
|
||
observations: observationsText,
|
||
photos: files,
|
||
aiAnalysis: analyses // Guardar todos los análisis
|
||
}
|
||
}))
|
||
console.log(`🤖 FULL MODE: Respuesta auto-completada con: ${suggestedAnswer}`)
|
||
console.log(`📝 Observaciones guardadas:`, observationsText)
|
||
}
|
||
// In ASSISTED mode, suggest in observations
|
||
else if (checklist.ai_mode === 'assisted') {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
observations: `${suggestedAnswer ? `[IA Sugiere: ${suggestedAnswer}]\n` : ''}${observationsText}`,
|
||
photos: files,
|
||
aiAnalysis: analyses // Guardar todos los análisis
|
||
}
|
||
}))
|
||
console.log(`🤖 ASSISTED MODE: Sugerencia agregada a observaciones`)
|
||
console.log(`📝 Observaciones guardadas:`, `${suggestedAnswer ? `[IA Sugiere: ${suggestedAnswer}]\n` : ''}${observationsText}`)
|
||
}
|
||
// Siempre guardar observaciones incluso si no hay modo específico o sugerencia
|
||
else if (observationsText) {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: {
|
||
...(prev[questionId] || { value: '', observations: '', photos: [] }),
|
||
observations: observationsText,
|
||
photos: files,
|
||
aiAnalysis: analyses // Guardar todos los análisis
|
||
}
|
||
}))
|
||
console.log(`🤖 Análisis IA guardado en observaciones:`, observationsText)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error al analizar fotos con IA:', error)
|
||
// Don't block the user if AI fails
|
||
} 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-2 mb-6 justify-center">
|
||
{/* Flecha izquierda */}
|
||
<button
|
||
onClick={() => setQuestionPage((p) => Math.max(0, p - 1))}
|
||
disabled={questionPage === 0}
|
||
className={`w-8 h-8 rounded-full border flex items-center justify-center 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`}
|
||
title="Anterior"
|
||
>
|
||
←
|
||
</button>
|
||
{/* Números de preguntas */}
|
||
{visibleBlock.map((q, idx) => {
|
||
const globalIdx = startIdx + idx;
|
||
const answered = answers[q.id]?.value;
|
||
let base = 'w-10 h-10 rounded-full border shadow-lg flex items-center justify-center text-lg font-bold transition-all select-none';
|
||
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-8 h-8 rounded-full border flex items-center justify-center 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`}
|
||
title="Siguiente"
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
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-4xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h2 className="text-2xl font-bold text-gray-900">
|
||
Nueva Inspección: {checklist.name}
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* AI Mode Banner */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className={`mb-4 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-center gap-2">
|
||
<span className="text-xl">🤖</span>
|
||
<div>
|
||
<p className={`text-sm font-medium ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-900' : 'text-blue-900'
|
||
}`}>
|
||
{checklist.ai_mode === 'full' ? 'Modo IA COMPLETO activado' : 'Modo IA ASISTIDO activado'}
|
||
</p>
|
||
<p className={`text-xs ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-700' : 'text-blue-700'
|
||
}`}>
|
||
{checklist.ai_mode === 'full'
|
||
? 'La IA completará automáticamente las respuestas al subir fotos. Revisa y ajusta si es necesario.'
|
||
: 'La IA sugerirá respuestas en las observaciones al subir fotos.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress indicator */}
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
Paso {step} de 3
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{step === 1 && 'Datos del Vehículo'}
|
||
{step === 2 && (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${answered}/${visible.length} preguntas respondidas`
|
||
})()}
|
||
{step === 3 && 'Firmas'}
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||
style={{
|
||
width: step === 1 ? '33%' : step === 2 ? (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${33 + (answered / visible.length) * 33}%`
|
||
})() : '100%'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 1: Vehicle Data */}
|
||
{step === 1 && (
|
||
<form onSubmit={handleVehicleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Placa del Vehículo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_plate}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_plate: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ABC-123"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Marca *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_brand}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_brand: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Toyota"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Modelo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_model}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_model: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Corolla"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Kilometraje
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={vehicleData.vehicle_km}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_km: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="50000"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Número de OR
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.or_number}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, or_number: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="OR-001"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Código de Operario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={user.employee_code || 'No asignado'}
|
||
readOnly
|
||
className="w-full px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed"
|
||
title="Este código se asigna automáticamente del perfil del usuario"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nº de Pedido
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.order_number}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, order_number: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="PED-12345"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
>
|
||
{loading ? 'Creando...' : 'Continuar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Step 2: Questions */}
|
||
{step === 2 && questions.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Cargando preguntas...</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && questions.length > 0 && !currentQuestion && (
|
||
<div className="text-center py-12">
|
||
<p className="text-red-500">No hay más preguntas disponibles</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && currentQuestion && (
|
||
<div className="space-y-6">
|
||
{/* Barra de navegación de preguntas */}
|
||
<QuestionNavigator />
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="text-sm text-gray-600 mb-1">
|
||
Sección: <strong>{currentQuestion.section}</strong>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{currentQuestion.text}
|
||
</h3>
|
||
{currentQuestion.points > 0 && (
|
||
<div className="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-4">
|
||
{/* Answer input based on type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Respuesta *
|
||
</label>
|
||
|
||
<QuestionAnswerInput
|
||
question={currentQuestion}
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(newValue) => {
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||
}))
|
||
}}
|
||
onSave={() => setTimeout(() => saveAnswer(currentQuestion.id), 500)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Observations */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Observaciones (opcional)
|
||
</label>
|
||
<textarea
|
||
value={answers[currentQuestion.id]?.observations || ''}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: {
|
||
...(prev[currentQuestion.id] || { value: '', observations: '', photos: [] }),
|
||
observations: e.target.value
|
||
}
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
placeholder="Notas adicionales..."
|
||
/>
|
||
</div>
|
||
|
||
{/* Photos */}
|
||
{currentQuestion.allow_photos && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Fotografías *
|
||
{currentQuestion.max_photos && (
|
||
<span className="ml-2 text-xs text-gray-600">
|
||
(máximo {currentQuestion.max_photos} foto{currentQuestion.max_photos > 1 ? 's' : ''})
|
||
</span>
|
||
)}
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||
<span className="ml-2 text-xs text-blue-600">
|
||
🤖 Análisis IA disponible
|
||
</span>
|
||
)}
|
||
</label>
|
||
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple={currentQuestion.max_photos > 1}
|
||
onChange={(e) => handlePhotoChange(currentQuestion.id, e.target.files)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
disabled={aiAnalyzing}
|
||
required={!answers[currentQuestion.id]?.photos?.length}
|
||
/>
|
||
|
||
{/* Photo Previews */}
|
||
{answers[currentQuestion.id]?.photos?.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="text-sm font-medium text-gray-700">
|
||
{answers[currentQuestion.id].photos.length} foto(s) cargada(s):
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{answers[currentQuestion.id].photos.map((photo, index) => (
|
||
<div key={index} className="relative group">
|
||
<img
|
||
src={URL.createObjectURL(photo)}
|
||
alt={`Foto ${index + 1}`}
|
||
className="w-full h-24 object-cover rounded-lg border border-gray-300"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemovePhoto(currentQuestion.id, index)}
|
||
className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Eliminar foto"
|
||
>
|
||
✕
|
||
</button>
|
||
<div className="text-xs text-center text-gray-600 mt-1">
|
||
Foto {index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Analyze Button */}
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleAnalyzePhotos(currentQuestion.id)}
|
||
disabled={aiAnalyzing}
|
||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition flex items-center justify-center gap-2"
|
||
>
|
||
{aiAnalyzing ? (
|
||
<>
|
||
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
|
||
<span>Analizando...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>🤖</span>
|
||
<span>Analizar Pregunta</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">Analizando {answers[currentQuestion.id]?.photos?.length || 0} imagen(es) con IA...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{answers[currentQuestion.id]?.photos?.length > 0 && !aiAnalyzing && (
|
||
<div className="text-sm text-gray-600 mt-1">
|
||
{checklist.ai_mode === 'full' && answers[currentQuestion.id]?.value && (
|
||
<span className="text-green-600">✓ Analizada</span>
|
||
)}
|
||
{checklist.ai_mode === 'assisted' && answers[currentQuestion.id]?.observations.includes('[IA Sugiere') && (
|
||
<span className="text-blue-600">✓ Sugerencia generada</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (currentQuestionIndex > 0) {
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex - 1)
|
||
}
|
||
}}
|
||
disabled={currentQuestionIndex === 0}
|
||
className="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"
|
||
>
|
||
← Anterior
|
||
</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
|
||
}
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex + 1)
|
||
}}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Siguiente →
|
||
</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
|
||
}
|
||
saveAnswer(currentQuestion.id)
|
||
proceedToSignatures()
|
||
}}
|
||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||
>
|
||
Completar y Firmar →
|
||
</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 automáticamente</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>
|
||
</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
|