Files
checklist/frontend/src/App.jsx

4466 lines
183 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import SignatureCanvas from 'react-signature-canvas'
import Sidebar from './Sidebar'
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="w-24 h-24 object-contain bg-white rounded-2xl shadow-lg" />
) : (
<div className="w-24 h-24 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)
setChecklists(Array.isArray(checklistsData) ? checklistsData : [])
} 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)
setInspections(Array.isArray(inspectionsData) ? inspectionsData : [])
} 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="w-12 h-12 object-contain bg-white rounded-xl shadow-lg" />
) : (
<div className="w-12 h-12 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,
api_key: config.api_key,
model_name: config.model_name
});
}
setLoading(false);
} catch (error) {
console.error('Error loading settings:', error);
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-20 w-auto rounded-xl border shadow" />
) : (
<div className="h-20 w-20 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-1.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>
<div className="space-y-3">
{filteredModels.map((model) => (
<label key={model.model_name} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${formData.model_name === model.model_name ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
<input
type="radio"
name="model_name"
value={model.model_name}
checked={formData.model_name === model.model_name}
onChange={() => setFormData({ ...formData, model_name: model.model_name })}
className="form-radio text-indigo-600"
/>
<span className="font-semibold">{model.model_name}</span>
<span className="text-xs text-gray-500">{model.description}</span>
</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 [formData, setFormData] = useState({
section: '',
text: '',
type: 'pass_fail',
points: 1,
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 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: 'pass_fail',
points: 1,
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 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()
} else {
alert('Error al eliminar pregunta')
}
} catch (error) {
console.error('Error:', error)
alert('Error al eliminar pregunta')
}
}
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)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
{showCreateForm ? 'Cancelar' : '+ Nueva Pregunta'}
</button>
</div>
{/* Create Form */}
{showCreateForm && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<form onSubmit={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">
Tipo de pregunta *
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
required
>
<option value="pass_fail">Pasa/Falla</option>
<option value="good_bad">Bueno/Malo</option>
<option value="text">Texto libre</option>
<option value="number">Número</option>
</select>
</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>
{/* Pregunta Condicional */}
<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 (opcional)</h4>
<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
})}
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 => (q.type === 'pass_fail' || q.type === 'good_bad') && !q.parent_question_id)
.map(q => (
<option key={q.id} value={q.id}>
#{q.id} - {q.text.substring(0, 50)}{q.text.length > 50 ? '...' : ''}
</option>
))
}
</select>
<p className="text-xs text-gray-500 mt-1">
Esta pregunta aparecerá solo si se responde 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?.type === 'pass_fail') {
return [
<option key="pass" value="pass"> Pasa</option>,
<option key="fail" value="fail"> Falla</option>
]
} else if (parentQ?.type === 'good_bad') {
return [
<option key="good" value="good"> Bueno</option>,
<option key="bad" value="bad"> Malo</option>
]
}
return null
})()}
</select>
<p className="text-xs text-gray-500 mt-1">
La pregunta solo se mostrará con esta respuesta
</p>
</div>
</div>
</div>
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */}
{checklist.ai_mode !== 'off' && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-purple-900 mb-3">🤖 Prompt de IA (opcional)</h4>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Prompt personalizado para análisis de fotos
</label>
<textarea
value={formData.ai_prompt}
onChange={(e) => setFormData({ ...formData, ai_prompt: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows="3"
placeholder="Ej: Analiza si las luces delanteras están encendidas y funcionando correctamente. Verifica que ambas luces tengan brillo uniforme y no presenten daños visibles."
/>
<p className="text-xs text-gray-500 mt-1">
Este prompt guiará a la IA para analizar las fotos específicamente para esta pregunta. Si la foto no corresponde al contexto, la IA sugerirá cambiarla.
</p>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Puntos
</label>
<input
type="number"
min="0"
value={formData.points}
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div 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"
>
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}
className={`p-4 hover:bg-gray-50 flex justify-between items-start ${
isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : ''
}`}
>
<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>
)}
</div>
</div>
</div>
</div>
<button
onClick={() => handleDeleteQuestion(question.id)}
className="ml-4 text-red-600 hover:text-red-700 text-sm"
>
Eliminar
</button>
</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>
</div>
)
}
function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection }) {
const [showCreateModal, setShowCreateModal] = useState(false)
const [showQuestionsModal, setShowQuestionsModal] = useState(false)
const [showEditPermissionsModal, setShowEditPermissionsModal] = useState(false)
const [selectedChecklist, setSelectedChecklist] = useState(null)
const [creating, setCreating] = useState(false)
const [updating, setUpdating] = useState(false)
const [mechanics, setMechanics] = useState([])
const [formData, setFormData] = useState({
name: '',
description: '',
ai_mode: 'off',
scoring_enabled: true,
mechanic_ids: []
})
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)
}
}
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)
}
}
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>
)}
{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>
) : (
checklists.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">
<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">
{user.role === 'admin' && (
<>
<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>
))
)}
{/* 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 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>
)}
</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>
) : (
<>
{/* Client Info */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-2">Información del Cliente</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Cliente:</span>
<span className="ml-2 font-medium">{inspection.client_name || '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)
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 (
<>
<div className="space-y-4">
{inspections.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>
{/* 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: '',
client_name: '',
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,
client_name: vehicleData.client_name || 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]
if (!answer?.value && answer?.value !== '') return // Don't save if no value
try {
const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || ''
// Determine status based on answer value
let status = 'ok'
if (question.type === 'pass_fail') {
status = answer.value === 'pass' ? 'ok' : 'critical'
} else if (question.type === 'good_bad') {
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,
status: status,
comment: answer.observations || 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 filesArray = Array.from(files)
// Update photos immediately
setAnswers({
...answers,
[questionId]: {
...answers[questionId],
photos: filesArray
}
})
// If AI mode is assisted or full, analyze the photos
if ((checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && filesArray.length > 0) {
await analyzePhotosWithAI(questionId, filesArray)
}
}
const analyzePhotosWithAI = async (questionId, files) => {
const question = questions.find(q => q.id === questionId)
if (!question) return
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
const analyses = []
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
formData.append('question_id', question.id.toString())
// Include custom prompt if available
if (question.ai_prompt) {
formData.append('custom_prompt', question.ai_prompt)
}
const response = await fetch(`${API_URL}/api/analyze-image`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
})
if (response.ok) {
const result = await response.json()
// Check if AI analysis was successful
if (result.success && result.analysis) {
analyses.push(result)
console.log('✅ Análisis IA:', result)
} 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.')
}
}
} else {
console.warn('⚠️ Error HTTP en análisis IA:', response.status, await response.text())
}
}
if (analyses.length > 0) {
// Process analysis results
const firstResult = analyses[0]
const analysis = firstResult.analysis
let suggestedAnswer = null
let observationsText = ''
// 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}`
}
// Map status to answer based on question type
if (question.type === 'pass_fail') {
if (status === 'ok') {
suggestedAnswer = 'pass'
} else if (status === 'critical' || status === 'minor') {
suggestedAnswer = 'fail'
}
} else if (question.type === 'good_bad') {
if (status === 'ok') {
suggestedAnswer = 'good'
} else if (status === 'minor') {
suggestedAnswer = 'regular'
} else if (status === 'critical') {
suggestedAnswer = 'bad'
}
}
} else if (typeof analysis === 'string') {
// Fallback for plain text responses
observationsText = `🤖 Análisis IA:\n${analysis}`
const analysisLower = analysis.toLowerCase()
if (question.type === 'pass_fail') {
if (analysisLower.includes('pasa') || analysisLower.includes('correcto') || analysisLower.includes('bueno') || analysisLower.includes('ok')) {
suggestedAnswer = 'pass'
} else if (analysisLower.includes('falla') || analysisLower.includes('incorrecto') || analysisLower.includes('problema') || analysisLower.includes('critical')) {
suggestedAnswer = 'fail'
}
} else if (question.type === 'good_bad') {
if (analysisLower.includes('bueno') || analysisLower.includes('excelente')) {
suggestedAnswer = 'good'
} else if (analysisLower.includes('regular') || analysisLower.includes('aceptable')) {
suggestedAnswer = 'regular'
} else if (analysisLower.includes('malo') || analysisLower.includes('deficiente')) {
suggestedAnswer = 'bad'
}
}
}
// In FULL mode, auto-fill the answer
if (checklist.ai_mode === 'full' && suggestedAnswer) {
setAnswers({
...answers,
[questionId]: {
...answers[questionId],
value: suggestedAnswer,
observations: observationsText,
photos: files
}
})
console.log(`🤖 FULL MODE: Respuesta auto-completada con: ${suggestedAnswer}`)
}
// In ASSISTED mode, suggest in observations
else if (checklist.ai_mode === 'assisted') {
setAnswers({
...answers,
[questionId]: {
...answers[questionId],
observations: `${suggestedAnswer ? `[IA Sugiere: ${suggestedAnswer}]\n` : ''}${observationsText}`,
photos: files
}
})
console.log(`🤖 ASSISTED MODE: Sugerencia agregada a observaciones`)
}
}
} 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"
>
&#8592;
</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"
>
&#8594;
</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 className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre del Cliente
</label>
<input
type="text"
value={vehicleData.client_name}
onChange={(e) => setVehicleData({ ...vehicleData, client_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Juan Pérez"
/>
</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>
{currentQuestion.type === 'pass_fail' && (
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
value="pass"
checked={answers[currentQuestion.id]?.value === 'pass'}
onChange={(e) => {
const newValue = e.target.value
setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
}))
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
className="mr-2"
/>
<span className="text-green-600"> Pasa</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="fail"
checked={answers[currentQuestion.id]?.value === 'fail'}
onChange={(e) => {
const newValue = e.target.value
setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
}))
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
className="mr-2"
/>
<span className="text-red-600"> Falla</span>
</label>
</div>
)}
{currentQuestion.type === 'good_bad' && (
<select
value={answers[currentQuestion.id]?.value}
onChange={(e) => {
const newValue = e.target.value
setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
}))
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Seleccionar...</option>
<option value="good">Bueno</option>
<option value="regular">Regular</option>
<option value="bad">Malo</option>
</select>
)}
{currentQuestion.type === 'numeric' && (
<input
type="number"
step="0.01"
value={answers[currentQuestion.id]?.value}
onChange={(e) => setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ingrese un valor numérico"
/>
)}
{currentQuestion.type === 'status' && (
<select
value={answers[currentQuestion.id]?.value}
onChange={(e) => setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: 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="">Seleccionar...</option>
<option value="ok">OK</option>
<option value="warning">Advertencia</option>
<option value="critical">Crítico</option>
</select>
)}
{currentQuestion.type === 'text' && (
<textarea
value={answers[currentQuestion.id]?.value}
onChange={(e) => setAnswers(prev => ({
...prev,
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows="3"
placeholder="Ingrese su respuesta"
/>
)}
</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], 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 (opcional)
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
<span className="ml-2 text-xs text-blue-600">
🤖 Análisis IA activado
</span>
)}
</label>
<input
type="file"
accept="image/*"
multiple
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}
/>
{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 imagen con IA...</span>
</div>
</div>
)}
{answers[currentQuestion.id]?.photos?.length > 0 && !aiAnalyzing && (
<div className="text-sm text-gray-600 mt-1">
{answers[currentQuestion.id].photos.length} foto(s) seleccionada(s)
{checklist.ai_mode === 'full' && answers[currentQuestion.id]?.value && (
<span className="ml-2 text-green-600"> Analizada</span>
)}
{checklist.ai_mode === 'assisted' && answers[currentQuestion.id]?.observations.includes('[IA Sugiere') && (
<span className="ml-2 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={() => {
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={() => {
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: '',
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: '', 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>
<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">
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: '', 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,
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">
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 [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'))
}
} 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)
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}
</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>
<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(0, 20).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>
</div>
)}
</div>
)
}
export default App