Files
checklist/frontend/src/App.jsx

3555 lines
138 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 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">
{/* Logo S de Syntria */}
<div className="w-24 h-24 bg-white rounded-2xl flex items-center justify-center shadow-lg transform hover:scale-105 transition-transform">
<svg viewBox="0 0 100 100" className="w-16 h-16">
<path
d="M 30 25 Q 20 25 20 35 Q 20 45 30 45 L 50 45 Q 60 45 60 55 Q 60 65 50 65 L 30 65 Q 20 65 20 75 Q 20 85 30 85 L 70 85 Q 80 85 80 75 Q 80 65 70 65 L 50 65 Q 40 65 40 55 Q 40 45 50 45 L 70 45 Q 80 45 80 35 Q 80 25 70 25 Z"
fill="url(#gradient)"
stroke="none"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#6366f1', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
</linearGradient>
</defs>
</svg>
</div>
</div>
<h1 className="text-4xl font-bold text-white mb-2">Syntria</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)
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">
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
<svg viewBox="0 0 100 100" className="w-8 h-8">
<path
d="M 30 25 Q 20 25 20 35 Q 20 45 30 45 L 50 45 Q 60 45 60 55 Q 60 65 50 65 L 30 65 Q 20 65 20 75 Q 20 85 30 85 L 70 85 Q 80 85 80 75 Q 80 65 70 65 L 50 65 Q 40 65 40 55 Q 40 45 50 45 L 70 45 Q 80 45 80 35 Q 80 25 70 25 Z"
fill="url(#headerGradient)"
stroke="none"
/>
<defs>
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#6366f1', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#a855f7', stopOpacity: 1 }} />
</linearGradient>
</defs>
</svg>
</div>
<div>
<h1 className="text-2xl font-bold text-white">Syntria</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 }) {
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(() => {
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 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">
<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>
{loading ? (
<div className="text-center py-12">
<div className="text-gray-500">Cargando configuración...</div>
</div>
) : (
<form onSubmit={handleSave} className="space-y-6">
{/* Provider Selection */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Proveedor de IA</h3>
<div className="grid grid-cols-2 gap-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-blue-500 bg-blue-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>
{/* API Key */}
<div className="bg-white border border-gray-200 rounded-lg p-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>
{/* Model Selection */}
<div className="bg-white border border-gray-200 rounded-lg p-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.id}
className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition ${
formData.model_name === model.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="model"
value={model.id}
checked={formData.model_name === model.id}
onChange={(e) => setFormData({ ...formData, model_name: e.target.value })}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="font-semibold text-gray-900">{model.name}</div>
<div className="text-sm text-gray-600 mt-1">{model.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Current Status */}
{aiConfig && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="text-green-600 text-xl"></span>
<div>
<div className="font-semibold text-green-900">Configuración Activa</div>
<div className="text-sm text-green-700 mt-1">
Proveedor: <strong className="capitalize">{aiConfig.provider}</strong> |
Modelo: <strong>{aiConfig.model_name}</strong>
</div>
<div className="text-xs text-green-600 mt-1">
Configurado el {new Date(aiConfig.created_at).toLocaleDateString('es-ES')}
</div>
</div>
</div>
</div>
)}
{/* Save Button */}
<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 syntria_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
})
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
})
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>
<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>
<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) => (
<div key={question.id} className="p-4 hover:bg-gray-50 flex justify-between items-start">
<div className="flex-1">
<div className="flex items-start gap-3">
<span className="text-xs text-gray-400 mt-1">#{question.id}</span>
<div>
<p className="text-gray-900">{question.text}</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 [selectedChecklist, setSelectedChecklist] = useState(null)
const [creating, setCreating] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
ai_mode: 'off',
scoring_enabled: true
})
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 })
onChecklistCreated()
} else {
alert('Error al crear checklist')
}
} catch (error) {
console.error('Error:', error)
alert('Error al crear checklist')
} finally {
setCreating(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">
<p className="text-gray-500">No hay checklists activos</p>
{user.role === 'admin' && (
<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>
) : (
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>
<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>
</div>
<div className="flex gap-2">
{user.role === 'admin' && (
<>
<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>
<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 })
}}
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>
)}
</div>
)
}
function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
const [loading, setLoading] = useState(true)
const [inspectionDetail, setInspectionDetail] = useState(null)
const [isInactivating, setIsInactivating] = 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] || '📋'
}
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">
{/* 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>
)}
</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 */}
{answer.photos && answer.photos.length > 0 && (
<div className="flex gap-2 flex-wrap">
{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">
<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-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>
</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
const initialAnswers = {}
questionsData.forEach(q => {
initialAnswers[q.id] = {
value: q.type === 'pass_fail' ? 'pass' : '',
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: Submit answer and move to next question
const handleAnswerSubmit = async () => {
if (!inspectionId || currentQuestionIndex >= questions.length) return
const question = questions[currentQuestionIndex]
const answer = answers[question.id]
setLoading(true)
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'
}
console.log('Submitting answer:', answerData)
const response = await fetch(`${API_URL}/api/answers`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(answerData)
})
console.log('Answer response status:', response.status)
if (response.ok) {
const savedAnswer = await response.json()
console.log('Answer saved:', savedAnswer)
// 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
})
}
}
// Move to next question or signatures
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1)
} else {
setStep(3)
}
} else {
const errorText = await response.text()
console.error('Error response:', errorText)
alert('Error al guardar respuesta: ' + errorText)
}
} catch (error) {
console.error('Error:', error)
alert('Error al guardar respuesta: ' + error.message)
} finally {
setLoading(false)
}
}
// 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}`)
// 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())
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)
}
}
const currentQuestion = questions[currentQuestionIndex]
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 && `Pregunta ${currentQuestionIndex + 1} de ${questions.length}`}
{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 / 3) * 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">
<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) => setAnswers({
...answers,
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
})}
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) => setAnswers({
...answers,
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
})}
className="mr-2"
/>
<span className="text-red-600"> Falla</span>
</label>
</div>
)}
{currentQuestion.type === 'good_bad' && (
<select
value={answers[currentQuestion.id]?.value}
onChange={(e) => setAnswers({
...answers,
[currentQuestion.id]: { ...answers[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="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({
...answers,
[currentQuestion.id]: { ...answers[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({
...answers,
[currentQuestion.id]: { ...answers[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({
...answers,
[currentQuestion.id]: { ...answers[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({
...answers,
[currentQuestion.id]: { ...answers[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">
{currentQuestionIndex > 0 && (
<button
type="button"
onClick={() => setCurrentQuestionIndex(currentQuestionIndex - 1)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Anterior
</button>
)}
<button
onClick={handleAnswerSubmit}
disabled={loading || !answers[currentQuestion.id]?.value}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
title={!answers[currentQuestion.id]?.value ? 'Debe seleccionar una respuesta' : ''}
>
{loading ? 'Guardando...' : currentQuestionIndex < questions.length - 1 ? 'Siguiente →' : 'Continuar a Firmas'}
</button>
</div>
{/* Debug info */}
{!answers[currentQuestion.id]?.value && (
<div className="text-sm text-red-600 mt-2">
Debe seleccionar una respuesta para continuar
</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">
<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'
: 'bg-blue-100 text-blue-700'
}`}>
{u.role === 'admin' ? '👑 Admin' : '🔧 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>
</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>
</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: ''
})
const [appliedFilters, setAppliedFilters] = useState({
date: '',
mechanicId: '',
checklistId: ''
})
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')
}
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: '' }
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-4 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 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