4466 lines
183 KiB
JavaScript
4466 lines
183 KiB
JavaScript
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import SignatureCanvas from 'react-signature-canvas'
|
||
import Sidebar from './Sidebar'
|
||
|
||
function App() {
|
||
const [user, setUser] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
// Verificar si hay token guardado
|
||
const token = localStorage.getItem('token')
|
||
const userData = localStorage.getItem('user')
|
||
|
||
if (token && userData) {
|
||
setUser(JSON.parse(userData))
|
||
}
|
||
setLoading(false)
|
||
}, [])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||
<div className="text-xl">Cargando...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Router>
|
||
<div className="min-h-screen bg-gray-50">
|
||
{!user ? (
|
||
<LoginPage setUser={setUser} />
|
||
) : (
|
||
<DashboardPage user={user} setUser={setUser} />
|
||
)}
|
||
</div>
|
||
</Router>
|
||
)
|
||
}
|
||
|
||
// Componente de Login
|
||
function LoginPage({ setUser }) {
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const res = await fetch(`${API_URL}/api/config/logo`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
const handleLogin = async (e) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
setLoading(true)
|
||
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/auth/login`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ username, password }),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || 'Error al iniciar sesión')
|
||
}
|
||
|
||
console.log('Login successful, token:', data.access_token.substring(0, 20) + '...')
|
||
|
||
// Guardar token y usuario
|
||
localStorage.setItem('token', data.access_token)
|
||
localStorage.setItem('user', JSON.stringify(data.user))
|
||
setUser(data.user)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-600 via-purple-600 to-blue-600 px-4">
|
||
<div className="max-w-md w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||
{/* Header con Logo */}
|
||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-center">
|
||
<div className="flex justify-center mb-4">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="w-24 h-24 object-contain bg-white rounded-2xl shadow-lg" />
|
||
) : (
|
||
<div className="w-24 h-24 bg-white rounded-2xl flex items-center justify-center shadow-lg text-gray-400">Sin logo</div>
|
||
)}
|
||
</div>
|
||
<h1 className="text-4xl font-bold text-white mb-2">AYUTEC</h1>
|
||
<p className="text-indigo-100 text-sm">Sistema Inteligente de Inspecciones</p>
|
||
</div>
|
||
|
||
{/* Formulario */}
|
||
<div className="px-8 py-8">
|
||
<form onSubmit={handleLogin} className="space-y-6">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||
placeholder="Ingresa tu usuario"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Contraseña
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||
placeholder="••••••••"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm flex items-center gap-2">
|
||
<span>⚠️</span>
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-3 rounded-xl font-semibold hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none shadow-lg"
|
||
>
|
||
{loading ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||
Iniciando sesión...
|
||
</span>
|
||
) : (
|
||
'Iniciar Sesión'
|
||
)}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente Dashboard
|
||
function DashboardPage({ user, setUser }) {
|
||
const [checklists, setChecklists] = useState([])
|
||
const [inspections, setInspections] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [activeTab, setActiveTab] = useState('checklists')
|
||
const [activeInspection, setActiveInspection] = useState(null)
|
||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const res = await fetch(`${API_URL}/api/config/logo`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log('Token:', token ? 'exists' : 'missing')
|
||
|
||
if (!token) {
|
||
console.warn('No token found, redirecting to login')
|
||
setUser(null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
// Cargar checklists
|
||
const checklistsRes = await fetch(`${API_URL}/api/checklists?active_only=true`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
},
|
||
})
|
||
|
||
console.log('Checklists response:', checklistsRes.status)
|
||
|
||
if (checklistsRes.status === 401) {
|
||
console.warn('Token expired or invalid, logging out')
|
||
localStorage.removeItem('token')
|
||
localStorage.removeItem('user')
|
||
setUser(null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
if (checklistsRes.ok) {
|
||
const checklistsData = await checklistsRes.json()
|
||
console.log('Checklists data:', checklistsData)
|
||
setChecklists(Array.isArray(checklistsData) ? checklistsData : [])
|
||
} else {
|
||
console.error('Error loading checklists:', checklistsRes.status)
|
||
setChecklists([])
|
||
}
|
||
|
||
// Cargar inspecciones
|
||
const inspectionsRes = await fetch(`${API_URL}/api/inspections?limit=10`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
},
|
||
})
|
||
|
||
console.log('Inspections response:', inspectionsRes.status)
|
||
|
||
if (inspectionsRes.ok) {
|
||
const inspectionsData = await inspectionsRes.json()
|
||
console.log('Inspections data:', inspectionsData)
|
||
setInspections(Array.isArray(inspectionsData) ? inspectionsData : [])
|
||
} else {
|
||
console.error('Error loading inspections:', inspectionsRes.status)
|
||
setInspections([])
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error loading data:', error)
|
||
setChecklists([])
|
||
setInspections([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('token')
|
||
localStorage.removeItem('user')
|
||
setUser(null)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-blue-50 flex">
|
||
{/* Sidebar */}
|
||
<Sidebar
|
||
user={user}
|
||
activeTab={activeTab}
|
||
setActiveTab={setActiveTab}
|
||
sidebarOpen={sidebarOpen}
|
||
setSidebarOpen={setSidebarOpen}
|
||
onLogout={handleLogout}
|
||
/>
|
||
|
||
{/* Main Content */}
|
||
<div className={`flex-1 flex flex-col transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-16'}`}>
|
||
{/* Header */}
|
||
<header className="bg-gradient-to-r from-indigo-600 to-purple-600 shadow-lg">
|
||
<div className="px-4 sm:px-6 lg:px-8 py-4">
|
||
<div className="flex items-center justify-between">
|
||
{/* Logo y Nombre del Sistema */}
|
||
<div className="flex items-center gap-3">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="w-12 h-12 object-contain bg-white rounded-xl shadow-lg" />
|
||
) : (
|
||
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg text-gray-400">Sin logo</div>
|
||
)}
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">AYUTEC</h1>
|
||
<p className="text-xs text-indigo-200">Sistema Inteligente de Inspecciones</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sección Activa */}
|
||
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-lg border border-white/20">
|
||
<span className="text-2xl">
|
||
{activeTab === 'checklists' && '📋'}
|
||
{activeTab === 'inspections' && '🔍'}
|
||
{activeTab === 'users' && '👥'}
|
||
{activeTab === 'reports' && '📊'}
|
||
{activeTab === 'settings' && '⚙️'}
|
||
</span>
|
||
<span className="text-white font-semibold">
|
||
{activeTab === 'checklists' && 'Checklists'}
|
||
{activeTab === 'inspections' && 'Inspecciones'}
|
||
{activeTab === 'users' && 'Usuarios'}
|
||
{activeTab === 'reports' && 'Reportes'}
|
||
{activeTab === 'settings' && 'Configuración'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 p-6">
|
||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden h-full border border-indigo-100">
|
||
<div className="p-6">
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando datos...</div>
|
||
</div>
|
||
) : activeTab === 'checklists' ? (
|
||
<ChecklistsTab
|
||
checklists={checklists}
|
||
user={user}
|
||
onChecklistCreated={loadData}
|
||
onStartInspection={setActiveInspection}
|
||
/>
|
||
) : activeTab === 'inspections' ? (
|
||
<InspectionsTab inspections={inspections} user={user} onUpdate={loadData} />
|
||
) : activeTab === 'settings' ? (
|
||
<SettingsTab user={user} />
|
||
) : activeTab === 'api-tokens' ? (
|
||
<APITokensTab user={user} />
|
||
) : activeTab === 'users' ? (
|
||
<UsersTab user={user} />
|
||
) : activeTab === 'reports' ? (
|
||
<ReportsTab user={user} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal de Inspección Activa */}
|
||
{activeInspection && (
|
||
<InspectionModal
|
||
checklist={activeInspection}
|
||
user={user}
|
||
onClose={() => setActiveInspection(null)}
|
||
onComplete={() => {
|
||
setActiveInspection(null)
|
||
loadData()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingsTab({ user }) {
|
||
// Estado para el logo
|
||
const [logoUrl, setLogoUrl] = useState(null);
|
||
const [logoUploading, setLogoUploading] = useState(false);
|
||
const [aiConfig, setAiConfig] = useState(null);
|
||
const [availableModels, setAvailableModels] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [formData, setFormData] = useState({
|
||
provider: 'openai',
|
||
api_key: '',
|
||
model_name: 'gpt-4o'
|
||
});
|
||
|
||
useEffect(() => {
|
||
const fetchLogo = async () => {
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
}
|
||
} catch {}
|
||
};
|
||
fetchLogo();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
}, []);
|
||
|
||
const loadSettings = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
// Cargar modelos disponibles
|
||
const modelsRes = await fetch(`${API_URL}/api/ai/models`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (modelsRes.ok) {
|
||
const models = await modelsRes.json();
|
||
setAvailableModels(models);
|
||
}
|
||
// Cargar configuración actual
|
||
const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (configRes.ok) {
|
||
const config = await configRes.json();
|
||
setAiConfig(config);
|
||
setFormData({
|
||
provider: config.provider,
|
||
api_key: config.api_key,
|
||
model_name: config.model_name
|
||
});
|
||
}
|
||
setLoading(false);
|
||
} catch (error) {
|
||
console.error('Error loading settings:', error);
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleLogoUpload = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
setLogoUploading(true);
|
||
try {
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const token = localStorage.getItem('token');
|
||
const formDataLogo = new FormData();
|
||
formDataLogo.append('file', file);
|
||
const res = await fetch(`${API_URL}/api/config/logo`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formDataLogo
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setLogoUrl(data.logo_url);
|
||
alert('Logo actualizado correctamente');
|
||
} else {
|
||
alert('Error al subir el logo');
|
||
}
|
||
} catch {
|
||
alert('Error al subir el logo');
|
||
} finally {
|
||
setLogoUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||
const response = await fetch(`${API_URL}/api/ai/configuration`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
});
|
||
if (response.ok) {
|
||
alert('Configuración guardada correctamente');
|
||
loadSettings();
|
||
} else {
|
||
alert('Error al guardar configuración');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
alert('Error al guardar configuración');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const filteredModels = availableModels.filter(m => m.provider === formData.provider);
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<form onSubmit={handleSave}>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Logo del Sistema</h2>
|
||
<div className="flex items-center gap-6 mt-2">
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="Logo" className="h-20 w-auto rounded-xl border shadow" />
|
||
) : (
|
||
<div className="h-20 w-20 bg-gray-200 rounded-xl flex items-center justify-center text-gray-400">Sin logo</div>
|
||
)}
|
||
<div>
|
||
<input type="file" accept="image/*" onChange={handleLogoUpload} disabled={logoUploading} />
|
||
{logoUploading && <span className="ml-2 text-blue-600">Subiendo...</span>}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-2">El logo se mostrará en el login y en la página principal.</p>
|
||
</div>
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Configuración de IA</h2>
|
||
<p className="text-sm text-gray-600 mt-1">Configura el proveedor y modelo de IA para análisis de imágenes</p>
|
||
<div className="flex gap-4 mt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'openai', model_name: 'gpt-4o' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'openai' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">🤖</div>
|
||
<div className="font-semibold">OpenAI</div>
|
||
<div className="text-xs text-gray-600 mt-1">GPT-4, GPT-4 Vision</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormData({ ...formData, provider: 'gemini', model_name: 'gemini-1.5-pro' })}
|
||
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'gemini' ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||
>
|
||
<div className="text-4xl mb-2">✨</div>
|
||
<div className="font-semibold">Google Gemini</div>
|
||
<div className="text-xs text-gray-600 mt-1">Gemini Pro, Flash</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">API Key</h3>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{formData.provider === 'openai' ? 'OpenAI API Key' : 'Google AI API Key'}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData.api_key}
|
||
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'}
|
||
required
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
{formData.provider === 'openai' ? (
|
||
<>Obtén tu API key en <a href="https://platform.openai.com/api-keys" target="_blank" className="text-blue-600 hover:underline">OpenAI Platform</a></>
|
||
) : (
|
||
<>Obtén tu API key en <a href="https://makersuite.google.com/app/apikey" target="_blank" className="text-blue-600 hover:underline">Google AI Studio</a></>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modelo de IA</h3>
|
||
<div className="space-y-3">
|
||
{filteredModels.map((model) => (
|
||
<label key={model.model_name} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${formData.model_name === model.model_name ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
|
||
<input
|
||
type="radio"
|
||
name="model_name"
|
||
value={model.model_name}
|
||
checked={formData.model_name === model.model_name}
|
||
onChange={() => setFormData({ ...formData, model_name: model.model_name })}
|
||
className="form-radio text-indigo-600"
|
||
/>
|
||
<span className="font-semibold">{model.model_name}</span>
|
||
<span className="text-xs text-gray-500">{model.description}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg disabled:opacity-50 disabled:transform-none"
|
||
disabled={saving}
|
||
>
|
||
{saving ? 'Guardando...' : 'Guardar Configuración'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function APITokensTab({ user }) {
|
||
const [tokens, setTokens] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [newTokenDescription, setNewTokenDescription] = useState('')
|
||
const [createdToken, setCreatedToken] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadTokens()
|
||
}, [])
|
||
|
||
const loadTokens = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setTokens(data)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading tokens:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateToken = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ description: newTokenDescription || null }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCreatedToken(data.token)
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al crear token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear token')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleRevokeToken = async (tokenId) => {
|
||
if (!confirm('¿Estás seguro de revocar este token? Esta acción no se puede deshacer.')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/users/me/tokens/${tokenId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
alert('Token revocado correctamente')
|
||
loadTokens()
|
||
} else {
|
||
alert('Error al revocar token')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al revocar token')
|
||
}
|
||
}
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text)
|
||
alert('Token copiado al portapapeles')
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<div className="mb-6 flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-gray-900">Mis API Tokens</h2>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
Genera tokens para acceder a la API sin necesidad de login
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Generar Nuevo Token
|
||
</button>
|
||
</div>
|
||
|
||
{/* Modal de Token Creado */}
|
||
{createdToken && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-bold text-gray-900">Token Creado Exitosamente</h3>
|
||
<p className="text-sm text-yellow-600 mt-2">
|
||
⚠️ Guarda este token ahora. No podrás verlo de nuevo.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-4 mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 text-sm font-mono break-all">{createdToken}</code>
|
||
<button
|
||
onClick={() => copyToClipboard(createdToken)}
|
||
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition flex-shrink-0"
|
||
>
|
||
📋 Copiar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||
<p className="text-sm text-blue-900 font-semibold mb-2">Ejemplo de uso:</p>
|
||
<code className="text-xs text-blue-800 block">
|
||
curl -H "Authorization: Bearer {createdToken}" http://tu-api.com/api/inspections
|
||
</code>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setCreatedToken(null)}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Formulario de Crear Token */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||
<h3 className="text-lg font-bold text-gray-900 mb-4">Generar Nuevo Token</h3>
|
||
|
||
<form onSubmit={handleCreateToken}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción (opcional)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newTokenDescription}
|
||
onChange={(e) => setNewTokenDescription(e.target.value)}
|
||
placeholder="ej: Integración con sistema X"
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Te ayuda a identificar para qué usas este token
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateForm(false)
|
||
setNewTokenDescription('')
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={creating}
|
||
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
||
>
|
||
{creating ? 'Generando...' : 'Generar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Lista de Tokens */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando tokens...</div>
|
||
</div>
|
||
) : tokens.length === 0 ? (
|
||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
||
<div className="text-4xl mb-3">🔑</div>
|
||
<p className="text-gray-600 mb-2">No tienes tokens API creados</p>
|
||
<p className="text-sm text-gray-500">Genera uno para acceder a la API sin login</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{tokens.map((token) => (
|
||
<div
|
||
key={token.id}
|
||
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
||
>
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-lg">🔑</span>
|
||
<h4 className="font-semibold text-gray-900">
|
||
{token.description || 'Token sin descripción'}
|
||
</h4>
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||
token.is_active
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-red-100 text-red-800'
|
||
}`}>
|
||
{token.is_active ? 'Activo' : 'Revocado'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
<div>
|
||
<span className="text-gray-500">Creado:</span>{' '}
|
||
{new Date(token.created_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
{token.last_used_at && (
|
||
<div>
|
||
<span className="text-gray-500">Último uso:</span>{' '}
|
||
{new Date(token.last_used_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
)}
|
||
{!token.last_used_at && (
|
||
<div className="text-yellow-600">
|
||
⚠️ Nunca usado
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{token.is_active && (
|
||
<button
|
||
onClick={() => handleRevokeToken(token.id)}
|
||
className="ml-4 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||
>
|
||
Revocar
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Información de Ayuda */}
|
||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<span className="text-blue-600 text-xl">ℹ️</span>
|
||
<div className="flex-1 text-sm text-blue-900">
|
||
<p className="font-semibold mb-2">¿Cómo usar los tokens API?</p>
|
||
<p className="mb-2">
|
||
Los tokens API te permiten acceder a todos los endpoints sin necesidad de hacer login.
|
||
Son perfectos para integraciones, scripts automatizados o aplicaciones externas.
|
||
</p>
|
||
<p className="mb-2">
|
||
Incluye el token en el header <code className="bg-blue-100 px-1 py-0.5 rounded">Authorization</code> de tus requests:
|
||
</p>
|
||
<code className="block bg-blue-100 p-2 rounded text-xs mt-2">
|
||
Authorization: Bearer AYUTEC_tu_token_aqui
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function QuestionsManagerModal({ checklist, onClose }) {
|
||
const [questions, setQuestions] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [formData, setFormData] = useState({
|
||
section: '',
|
||
text: '',
|
||
type: 'pass_fail',
|
||
points: 1,
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadQuestions()
|
||
}, [])
|
||
|
||
const loadQuestions = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setQuestions(data.questions || [])
|
||
}
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading questions:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateQuestion = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
...formData,
|
||
checklist_id: checklist.id
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateForm(false)
|
||
setFormData({
|
||
section: '',
|
||
text: '',
|
||
type: 'pass_fail',
|
||
points: 1,
|
||
allow_photos: true,
|
||
max_photos: 3,
|
||
requires_comment_on_fail: false,
|
||
send_notification: false,
|
||
parent_question_id: null,
|
||
show_if_answer: '',
|
||
ai_prompt: ''
|
||
})
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al crear pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear pregunta')
|
||
}
|
||
}
|
||
|
||
const handleDeleteQuestion = async (questionId) => {
|
||
if (!confirm('¿Estás seguro de eliminar esta pregunta?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/questions/${questionId}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadQuestions()
|
||
} else {
|
||
alert('Error al eliminar pregunta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al eliminar pregunta')
|
||
}
|
||
}
|
||
|
||
const questionsBySection = questions.reduce((acc, q) => {
|
||
const section = q.section || 'Sin sección'
|
||
if (!acc[section]) acc[section] = []
|
||
acc[section].push(q)
|
||
return acc
|
||
}, {})
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-purple-600 text-white p-6 rounded-t-lg">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Gestionar Preguntas</h2>
|
||
<p className="mt-1 opacity-90">{checklist.name}</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<div>
|
||
<p className="text-sm text-gray-600">
|
||
Total de preguntas: <strong>{questions.length}</strong> |
|
||
Puntuación máxima: <strong>{questions.reduce((sum, q) => sum + (q.points || 0), 0)}</strong>
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
{showCreateForm ? 'Cancelar' : '+ Nueva Pregunta'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Create Form */}
|
||
{showCreateForm && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||
<form onSubmit={handleCreateQuestion} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Sección
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.section}
|
||
onChange={(e) => setFormData({ ...formData, section: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Motor, Frenos, Documentación"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Tipo de pregunta *
|
||
</label>
|
||
<select
|
||
value={formData.type}
|
||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
required
|
||
>
|
||
<option value="pass_fail">Pasa/Falla</option>
|
||
<option value="good_bad">Bueno/Malo</option>
|
||
<option value="text">Texto libre</option>
|
||
<option value="number">Número</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Texto de la pregunta *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.text}
|
||
onChange={(e) => setFormData({ ...formData, text: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
placeholder="Ej: Estado de las pastillas de freno"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Pregunta Condicional */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-blue-900 mb-3">⚡ Pregunta Condicional (opcional)</h4>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Pregunta padre
|
||
</label>
|
||
<select
|
||
value={formData.parent_question_id || ''}
|
||
onChange={(e) => setFormData({
|
||
...formData,
|
||
parent_question_id: e.target.value ? parseInt(e.target.value) : null
|
||
})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
>
|
||
<option value="">Ninguna (pregunta principal)</option>
|
||
{questions
|
||
.filter(q => (q.type === 'pass_fail' || q.type === 'good_bad') && !q.parent_question_id)
|
||
.map(q => (
|
||
<option key={q.id} value={q.id}>
|
||
#{q.id} - {q.text.substring(0, 50)}{q.text.length > 50 ? '...' : ''}
|
||
</option>
|
||
))
|
||
}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Esta pregunta aparecerá solo si se responde la pregunta padre
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Mostrar si la respuesta es:
|
||
</label>
|
||
<select
|
||
value={formData.show_if_answer}
|
||
onChange={(e) => setFormData({ ...formData, show_if_answer: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||
disabled={!formData.parent_question_id}
|
||
>
|
||
<option value="">Seleccione...</option>
|
||
{formData.parent_question_id && (() => {
|
||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||
if (parentQ?.type === 'pass_fail') {
|
||
return [
|
||
<option key="pass" value="pass">✓ Pasa</option>,
|
||
<option key="fail" value="fail">✗ Falla</option>
|
||
]
|
||
} else if (parentQ?.type === 'good_bad') {
|
||
return [
|
||
<option key="good" value="good">✓ Bueno</option>,
|
||
<option key="bad" value="bad">✗ Malo</option>
|
||
]
|
||
}
|
||
return null
|
||
})()}
|
||
</select>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
La pregunta solo se mostrará con esta respuesta
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||
<h4 className="text-sm font-semibold text-purple-900 mb-3">🤖 Prompt de IA (opcional)</h4>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Prompt personalizado para análisis de fotos
|
||
</label>
|
||
<textarea
|
||
value={formData.ai_prompt}
|
||
onChange={(e) => setFormData({ ...formData, ai_prompt: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
rows="3"
|
||
placeholder="Ej: Analiza si las luces delanteras están encendidas y funcionando correctamente. Verifica que ambas luces tengan brillo uniforme y no presenten daños visibles."
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Este prompt guiará a la IA para analizar las fotos específicamente para esta pregunta. Si la foto no corresponde al contexto, la IA sugerirá cambiarla.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Puntos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={formData.points}
|
||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.allow_photos}
|
||
onChange={(e) => setFormData({ ...formData, allow_photos: e.target.checked })}
|
||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Permitir fotos
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Máx. fotos
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
value={formData.max_photos}
|
||
onChange={(e) => setFormData({ ...formData, max_photos: parseInt(e.target.value) })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||
disabled={!formData.allow_photos}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.send_notification}
|
||
onChange={(e) => setFormData({ ...formData, send_notification: e.target.checked })}
|
||
className="w-4 h-4 text-yellow-600 border-gray-300 rounded focus:ring-yellow-500"
|
||
/>
|
||
<label className="text-sm font-medium text-gray-700">
|
||
🔔 Enviar notificación cuando se responda esta pregunta
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mt-2 ml-6">
|
||
Si activas esta opción, se enviará una notificación automática al administrador cada vez que un mecánico responda esta pregunta.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||
>
|
||
Crear Pregunta
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Questions List */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando preguntas...</div>
|
||
</div>
|
||
) : questions.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No hay preguntas en este checklist</p>
|
||
<p className="text-sm text-gray-400 mt-2">Crea la primera pregunta para comenzar</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{Object.entries(questionsBySection).map(([section, sectionQuestions]) => (
|
||
<div key={section} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3">
|
||
<h3 className="font-semibold text-gray-900">{section}</h3>
|
||
<p className="text-sm text-gray-600">
|
||
{sectionQuestions.length} preguntas | {sectionQuestions.reduce((sum, q) => sum + (q.points || 0), 0)} puntos
|
||
</p>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{sectionQuestions.map((question) => {
|
||
const isSubQuestion = question.parent_question_id
|
||
const parentQuestion = isSubQuestion ? questions.find(q => q.id === question.parent_question_id) : null
|
||
|
||
return (
|
||
<div
|
||
key={question.id}
|
||
className={`p-4 hover:bg-gray-50 flex justify-between items-start ${
|
||
isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : ''
|
||
}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
{isSubQuestion && (
|
||
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
|
||
⚡ Condicional
|
||
</span>
|
||
)}
|
||
<p className="text-gray-900">{question.text}</p>
|
||
</div>
|
||
{isSubQuestion && parentQuestion && (
|
||
<p className="text-xs text-blue-600 mt-1">
|
||
→ Aparece si <strong>#{question.parent_question_id}</strong> es <strong>{question.show_if_answer}</strong>
|
||
</p>
|
||
)}
|
||
<div className="flex gap-4 mt-2 text-sm text-gray-600">
|
||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||
{question.type}
|
||
</span>
|
||
<span>{question.points} pts</span>
|
||
{question.allow_photos && (
|
||
<span>📷 Máx {question.max_photos} fotos</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => handleDeleteQuestion(question.id)}
|
||
className="ml-4 text-red-600 hover:text-red-700 text-sm"
|
||
>
|
||
Eliminar
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection }) {
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [showQuestionsModal, setShowQuestionsModal] = useState(false)
|
||
const [showEditPermissionsModal, setShowEditPermissionsModal] = useState(false)
|
||
const [selectedChecklist, setSelectedChecklist] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
const [updating, setUpdating] = useState(false)
|
||
const [mechanics, setMechanics] = useState([])
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
const [editPermissionsData, setEditPermissionsData] = useState({
|
||
mechanic_ids: []
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadMechanics()
|
||
}, [])
|
||
|
||
const loadMechanics = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/users`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// Filtrar solo mecánicos activos
|
||
const mechanicUsers = data.filter(u =>
|
||
(u.role === 'mechanic' || u.role === 'mecanico') && u.is_active
|
||
)
|
||
setMechanics(mechanicUsers)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading mechanics:', error)
|
||
}
|
||
}
|
||
|
||
const handleCreate = async (e) => {
|
||
e.preventDefault()
|
||
setCreating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(formData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
onChecklistCreated()
|
||
} else {
|
||
alert('Error al crear checklist')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear checklist')
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleEditPermissions = async (e) => {
|
||
e.preventDefault()
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(editPermissionsData),
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
onChecklistCreated() // Reload checklists
|
||
} else {
|
||
alert('Error al actualizar permisos')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar permisos')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{user.role === 'admin' && (
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
+ Crear Checklist
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{checklists.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
{user.role === 'admin' ? (
|
||
<>
|
||
<p className="text-gray-500">No hay checklists activos</p>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Crear tu primer checklist
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="text-4xl mb-3">🔒</div>
|
||
<p className="text-gray-700 font-semibold">No tienes checklists disponibles</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
Contacta con el administrador para que te asigne permisos a los checklists que necesites usar.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
checklists.map((checklist) => (
|
||
<div key={checklist.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-gray-900">{checklist.name}</h3>
|
||
<p className="text-sm text-gray-600 mt-1">{checklist.description}</p>
|
||
<div className="flex gap-4 mt-3 text-sm">
|
||
<span className="text-gray-500">
|
||
Puntuación máxima: <strong>{checklist.max_score}</strong>
|
||
</span>
|
||
<span className="text-gray-500">
|
||
Modo IA: <strong className="capitalize">{checklist.ai_mode}</strong>
|
||
</span>
|
||
</div>
|
||
{/* Mostrar permisos de mecánicos */}
|
||
{user.role === 'admin' && (
|
||
<div className="mt-2">
|
||
{!checklist.allowed_mechanics || checklist.allowed_mechanics.length === 0 ? (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||
🌍 Acceso Global - Todos los mecánicos
|
||
</span>
|
||
) : (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||
🔐 Restringido - {checklist.allowed_mechanics.length} mecánico{checklist.allowed_mechanics.length !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{user.role === 'admin' && (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setEditPermissionsData({
|
||
mechanic_ids: checklist.allowed_mechanics || []
|
||
})
|
||
setShowEditPermissionsModal(true)
|
||
}}
|
||
className="px-3 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all transform hover:scale-105 shadow-lg text-sm"
|
||
title="Editar permisos"
|
||
>
|
||
🔐 Permisos
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedChecklist(checklist)
|
||
setShowQuestionsModal(true)
|
||
}}
|
||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Gestionar Preguntas
|
||
</button>
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
</>
|
||
)}
|
||
{(user.role === 'mechanic' || user.role === 'mecanico') && (
|
||
<button
|
||
onClick={() => onStartInspection(checklist)}
|
||
className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-lg hover:from-green-600 hover:to-emerald-600 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
Nueva Inspección
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{/* Modal Gestionar Preguntas */}
|
||
{showQuestionsModal && selectedChecklist && (
|
||
<QuestionsManagerModal
|
||
checklist={selectedChecklist}
|
||
onClose={() => {
|
||
setShowQuestionsModal(false)
|
||
setSelectedChecklist(null)
|
||
onChecklistCreated()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Modal Crear Checklist */}
|
||
{showCreateModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Crear Nuevo Checklist</h2>
|
||
|
||
<form onSubmit={handleCreate} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Nombre del Checklist *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Ej: Mantenimiento Preventivo"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Descripción
|
||
</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="Descripción del checklist..."
|
||
rows="3"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Modo IA
|
||
</label>
|
||
<select
|
||
value={formData.ai_mode}
|
||
onChange={(e) => setFormData({ ...formData, ai_mode: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="off">🚫 Sin IA - Control manual total</option>
|
||
<option value="assisted">🤝 IA Asistida - Sugerencias en fotos</option>
|
||
<option value="full">🤖 IA Completa - Análisis automático</option>
|
||
</select>
|
||
|
||
{/* Descripción del modo seleccionado */}
|
||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||
{formData.ai_mode === 'off' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>Sin IA:</strong> El mecánico completa manualmente todas las respuestas.
|
||
Sin dependencia de internet o API.
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'assisted' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>IA Asistida:</strong> Cuando se suben fotos, la IA analiza y sugiere
|
||
estado, criticidad y observaciones. El mecánico acepta o modifica.
|
||
<div className="mt-1 text-xs">⚠️ Requiere OPENAI_API_KEY configurada</div>
|
||
</div>
|
||
)}
|
||
{formData.ai_mode === 'full' && (
|
||
<div className="text-sm text-blue-800">
|
||
<strong>IA Completa:</strong> El mecánico solo toma fotos y la IA responde
|
||
automáticamente todas las preguntas. Ideal para inspecciones rápidas masivas.
|
||
<div className="mt-1 text-xs">⚠️ Requiere OPENAI_API_KEY configurada</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.scoring_enabled}
|
||
onChange={(e) => setFormData({ ...formData, scoring_enabled: e.target.checked })}
|
||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
Habilitar sistema de puntuación
|
||
</label>
|
||
</div>
|
||
|
||
{/* Selector de Mecánicos Autorizados */}
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({ ...formData, mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: [...formData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setFormData({
|
||
...formData,
|
||
mechanic_ids: formData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||
<p className="text-sm text-yellow-800">
|
||
ℹ️ Después de crear el checklist, podrás agregar preguntas desde la API o directamente en la base de datos.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateModal(false)
|
||
setFormData({
|
||
name: '',
|
||
description: '',
|
||
ai_mode: 'off',
|
||
scoring_enabled: true,
|
||
mechanic_ids: []
|
||
})
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={creating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
disabled={creating}
|
||
>
|
||
{creating ? 'Creando...' : 'Crear Checklist'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Editar Permisos */}
|
||
{showEditPermissionsModal && selectedChecklist && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Editar Permisos de Checklist</h2>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{selectedChecklist.name}
|
||
</p>
|
||
|
||
<form onSubmit={handleEditPermissions} className="space-y-4">
|
||
<div className="border-t pt-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
🔐 Mecánicos Autorizados
|
||
</label>
|
||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-3 max-h-96 overflow-y-auto">
|
||
{mechanics.length === 0 ? (
|
||
<p className="text-sm text-gray-500">No hay mecánicos disponibles</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center pb-2 border-b">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.length === 0}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||
/>
|
||
<label className="ml-2 text-sm font-semibold text-green-700">
|
||
🌍 Todos los mecánicos (acceso global)
|
||
</label>
|
||
</div>
|
||
{mechanics.map((mechanic) => (
|
||
<div key={mechanic.id} className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editPermissionsData.mechanic_ids.includes(mechanic.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setEditPermissionsData({
|
||
mechanic_ids: [...editPermissionsData.mechanic_ids, mechanic.id]
|
||
})
|
||
} else {
|
||
setEditPermissionsData({
|
||
mechanic_ids: editPermissionsData.mechanic_ids.filter(id => id !== mechanic.id)
|
||
})
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">
|
||
{mechanic.full_name || mechanic.username} ({mechanic.email})
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
💡 Si no seleccionas ningún mecánico, todos podrán usar este checklist.
|
||
Si seleccionas mecánicos específicos, solo ellos tendrán acceso.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<p className="text-sm text-blue-800">
|
||
ℹ️ Los cambios se aplicarán inmediatamente. Los mecánicos que pierdan acceso ya no verán este checklist.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditPermissionsModal(false)
|
||
setSelectedChecklist(null)
|
||
setEditPermissionsData({ mechanic_ids: [] })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
disabled={updating}
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition disabled:opacity-50"
|
||
disabled={updating}
|
||
>
|
||
{updating ? 'Guardando...' : 'Guardar Permisos'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||
const [loading, setLoading] = useState(true)
|
||
const [inspectionDetail, setInspectionDetail] = useState(null)
|
||
const [isInactivating, setIsInactivating] = useState(false)
|
||
const [editingAnswerId, setEditingAnswerId] = useState(null)
|
||
const [editFormData, setEditFormData] = useState({})
|
||
const [showAuditLog, setShowAuditLog] = useState(false)
|
||
const [auditLogs, setAuditLogs] = useState([])
|
||
const [loadingAudit, setLoadingAudit] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const loadInspectionDetails = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Cargar la inspección completa con respuestas y checklist
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log('Inspection detail loaded:', data)
|
||
setInspectionDetail(data)
|
||
} else {
|
||
console.error('Error loading inspection:', response.status)
|
||
}
|
||
|
||
setLoading(false)
|
||
} catch (error) {
|
||
console.error('Error loading inspection details:', error)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
loadInspectionDetails()
|
||
}, [inspection.id, inspection.checklist_id])
|
||
|
||
const getStatusBadge = (status) => {
|
||
const statusConfig = {
|
||
ok: { bg: 'bg-green-100', text: 'text-green-800', label: '✓ OK' },
|
||
critical: { bg: 'bg-red-100', text: 'text-red-800', label: '✗ Crítico' },
|
||
minor: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '⚠ Menor' },
|
||
na: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'N/A' }
|
||
}
|
||
const config = statusConfig[status] || statusConfig.ok
|
||
return (
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${config.bg} ${config.text}`}>
|
||
{config.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const getCategoryIcon = (category) => {
|
||
const icons = {
|
||
'Motor': '🔧',
|
||
'Frenos': '🛑',
|
||
'Suspensión': '⚙️',
|
||
'Neumáticos': '🚗',
|
||
'Electricidad': '⚡',
|
||
'Carrocería': '🚙',
|
||
'Interior': '🪑',
|
||
'Documentación': '📄'
|
||
}
|
||
return icons[category] || '📋'
|
||
}
|
||
|
||
const loadAuditLog = async () => {
|
||
setLoadingAudit(true)
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/audit-log`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setAuditLogs(data)
|
||
setShowAuditLog(true)
|
||
} else {
|
||
alert('Error al cargar historial de cambios')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading audit log:', error)
|
||
alert('Error al cargar historial de cambios')
|
||
} finally {
|
||
setLoadingAudit(false)
|
||
}
|
||
}
|
||
|
||
const startEditAnswer = (answer) => {
|
||
setEditingAnswerId(answer.id)
|
||
setEditFormData({
|
||
answer_value: answer.answer_value || '',
|
||
status: answer.status || 'ok',
|
||
comment: answer.comment || '',
|
||
is_flagged: answer.is_flagged || false,
|
||
edit_comment: ''
|
||
})
|
||
}
|
||
|
||
const cancelEdit = () => {
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
}
|
||
|
||
const saveEdit = async (answerId) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/answers/${answerId}/admin-edit`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(editFormData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
// Recargar detalles de inspección
|
||
const inspectionResponse = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (inspectionResponse.ok) {
|
||
const data = await inspectionResponse.json()
|
||
setInspectionDetail(data)
|
||
}
|
||
setEditingAnswerId(null)
|
||
setEditFormData({})
|
||
alert('Respuesta actualizada correctamente')
|
||
} else {
|
||
alert('Error al actualizar respuesta')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving edit:', error)
|
||
alert('Error al guardar cambios')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||
{/* Header */}
|
||
<div className="bg-blue-600 text-white p-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">Inspección #{inspection.id}</h2>
|
||
<p className="mt-1 opacity-90">
|
||
{inspection.vehicle_plate} - {inspection.vehicle_brand} {inspection.vehicle_model}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white hover:bg-blue-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Info Cards */}
|
||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">OR Number</div>
|
||
<div className="text-lg font-bold">{inspection.or_number || 'N/A'}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Kilometraje</div>
|
||
<div className="text-lg font-bold">{inspection.vehicle_km} km</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Score Total</div>
|
||
<div className="text-lg font-bold">{inspection.score}/{inspection.max_score}</div>
|
||
</div>
|
||
<div className="bg-blue-700 bg-opacity-50 rounded-lg p-3">
|
||
<div className="text-xs opacity-75">Porcentaje</div>
|
||
<div className="text-lg font-bold">{inspection.percentage.toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-gray-500">Cargando detalles...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Client Info */}
|
||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||
<h3 className="font-semibold text-gray-900 mb-2">Información del Cliente</h3>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-600">Cliente:</span>
|
||
<span className="ml-2 font-medium">{inspection.client_name || 'N/A'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">Fecha:</span>
|
||
<span className="ml-2 font-medium">
|
||
{inspection.started_at ? new Date(inspection.started_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}) : 'N/A'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Debug Info */}
|
||
{inspectionDetail && (
|
||
<div className="bg-gray-100 rounded p-2 mb-4 text-xs">
|
||
<strong>Debug:</strong> Respuestas cargadas: {inspectionDetail.answers?.length || 0},
|
||
Preguntas disponibles: {inspectionDetail.checklist?.questions?.length || 0}
|
||
</div>
|
||
)}
|
||
|
||
{/* Answers by Category */}
|
||
{inspectionDetail && inspectionDetail.checklist && inspectionDetail.checklist.questions && (
|
||
<div className="space-y-6">
|
||
{Object.entries(
|
||
inspectionDetail.checklist.questions.reduce((acc, question) => {
|
||
const category = question.category || 'Sin categoría'
|
||
if (!acc[category]) acc[category] = []
|
||
acc[category].push(question)
|
||
return acc
|
||
}, {})
|
||
).map(([category, categoryQuestions]) => (
|
||
<div key={category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<div className="bg-gray-100 px-4 py-3 flex items-center gap-2">
|
||
<span className="text-2xl">{getCategoryIcon(category)}</span>
|
||
<h3 className="font-semibold text-gray-900">{category}</h3>
|
||
<span className="ml-auto text-sm text-gray-600">
|
||
{categoryQuestions.length} preguntas
|
||
</span>
|
||
</div>
|
||
<div className="divide-y divide-gray-200">
|
||
{categoryQuestions.map((question) => {
|
||
const answer = inspectionDetail.answers?.find(a => a.question_id === question.id)
|
||
return (
|
||
<div key={question.id} className="p-4 hover:bg-gray-50">
|
||
<div className="flex justify-between items-start gap-4">
|
||
<div className="flex-1">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-gray-400 text-sm mt-1">#{question.id}</div>
|
||
<div className="flex-1">
|
||
<p className="text-gray-900 font-medium">{question.text}</p>
|
||
{question.description && (
|
||
<p className="text-sm text-gray-500 mt-1">{question.description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{answer && (
|
||
<div className="mt-3 ml-10 space-y-2">
|
||
{editingAnswerId === answer.id ? (
|
||
// Modo Edición (solo admin)
|
||
<div className="bg-blue-50 border border-blue-300 rounded-lg p-4 space-y-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-blue-700 font-semibold">✏️ Editando Respuesta</span>
|
||
</div>
|
||
|
||
{/* Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||
<select
|
||
value={editFormData.status}
|
||
onChange={(e) => setEditFormData({...editFormData, status: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="ok">✓ OK</option>
|
||
<option value="warning">⚠ Advertencia</option>
|
||
<option value="critical">✗ Crítico</option>
|
||
<option value="na">N/A</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Answer Value (si aplica) */}
|
||
{question.type !== 'pass_fail' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Valor de Respuesta</label>
|
||
<input
|
||
type="text"
|
||
value={editFormData.answer_value}
|
||
onChange={(e) => setEditFormData({...editFormData, answer_value: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Comment */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Observación</label>
|
||
<textarea
|
||
value={editFormData.comment}
|
||
onChange={(e) => setEditFormData({...editFormData, comment: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
/>
|
||
</div>
|
||
|
||
{/* Flagged */}
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={editFormData.is_flagged}
|
||
onChange={(e) => setEditFormData({...editFormData, is_flagged: e.target.checked})}
|
||
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
|
||
/>
|
||
<label className="ml-2 text-sm text-gray-700">🚩 Marcar como señalado</label>
|
||
</div>
|
||
|
||
{/* Edit Comment */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Motivo del cambio (obligatorio)
|
||
</label>
|
||
<textarea
|
||
value={editFormData.edit_comment}
|
||
onChange={(e) => setEditFormData({...editFormData, edit_comment: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
placeholder="Explica por qué estás haciendo este cambio..."
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex gap-2 pt-2">
|
||
<button
|
||
onClick={() => saveEdit(answer.id)}
|
||
disabled={!editFormData.edit_comment}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Guardar Cambios
|
||
</button>
|
||
<button
|
||
onClick={cancelEdit}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Modo Vista Normal
|
||
<>
|
||
{/* Answer Value */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">Respuesta:</span>
|
||
{question.type === 'pass_fail' ? (
|
||
getStatusBadge(answer.status)
|
||
) : (
|
||
<span className="font-medium">{answer.answer_value}</span>
|
||
)}
|
||
{answer.is_flagged && (
|
||
<span className="text-red-600 text-sm">🚩 Señalado</span>
|
||
)}
|
||
{/* Botón Editar (solo admin) */}
|
||
{user?.role === 'admin' && inspection.status === 'completed' && (
|
||
<button
|
||
onClick={() => startEditAnswer(answer)}
|
||
className="ml-2 px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Comment */}
|
||
{answer.comment && (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded p-2">
|
||
<span className="text-xs text-yellow-800 font-medium">Observación:</span>
|
||
<p className="text-sm text-yellow-900 mt-1">{answer.comment}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Photos - NUEVO: miniaturas de media_files */}
|
||
{(answer.media_files && answer.media_files.length > 0) && (
|
||
<div className="flex gap-2 flex-wrap mt-2">
|
||
{answer.media_files.map((media, idx) => (
|
||
<img
|
||
key={idx}
|
||
src={media.file_path}
|
||
alt={`Foto ${idx + 1}`}
|
||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||
onClick={() => window.open(media.file_path, '_blank')}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Photos - compatibilidad legacy */}
|
||
{(answer.photos && answer.photos.length > 0) && (
|
||
<div className="flex gap-2 flex-wrap mt-2">
|
||
{answer.photos.map((photo, idx) => (
|
||
<img
|
||
key={idx}
|
||
src={photo}
|
||
alt={`Foto ${idx + 1}`}
|
||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||
onClick={() => window.open(photo, '_blank')}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Points */}
|
||
{question.points_value > 0 && (
|
||
<div className="text-sm">
|
||
<span className="text-gray-600">Puntos:</span>
|
||
<span className="ml-2 font-medium text-blue-600">
|
||
{answer.points_earned || 0}/{question.points_value}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!answer && (
|
||
<div className="mt-2 ml-10 text-sm text-gray-400 italic">
|
||
Sin respuesta
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Signatures */}
|
||
{inspectionDetail && inspectionDetail.mechanic_signature && (
|
||
<div className="mt-6 border-t pt-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Firma del Mecánico</h3>
|
||
<div className="bg-gray-50 rounded-lg p-4 inline-block">
|
||
<img
|
||
src={inspectionDetail.mechanic_signature}
|
||
alt="Firma Mecánico"
|
||
className="h-24 border border-gray-300 rounded"
|
||
/>
|
||
<p className="text-sm text-gray-600 mt-2">
|
||
Mecánico: {inspectionDetail.mechanic?.full_name || 'N/A'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<div className="flex gap-3">
|
||
{user?.role === 'admin' && (
|
||
<button
|
||
onClick={loadAuditLog}
|
||
disabled={loadingAudit}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition flex items-center gap-2"
|
||
>
|
||
<span>📜</span>
|
||
{loadingAudit ? 'Cargando...' : 'Ver Historial de Cambios'}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/pdf`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const contentType = response.headers.get('content-type')
|
||
if (contentType && contentType.includes('application/pdf')) {
|
||
const blob = await response.blob()
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `inspeccion_${inspection.id}_${inspection.vehicle_plate || 'sin-patente'}.pdf`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(url)
|
||
} else {
|
||
const data = await response.json()
|
||
if (data.pdf_url) {
|
||
const pdfRes = await fetch(data.pdf_url)
|
||
if (pdfRes.ok) {
|
||
const pdfBlob = await pdfRes.blob()
|
||
const pdfUrl = window.URL.createObjectURL(pdfBlob)
|
||
const a = document.createElement('a')
|
||
a.href = pdfUrl
|
||
a.download = `inspeccion_${inspection.id}_${inspection.vehicle_plate || 'sin-patente'}.pdf`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(pdfUrl)
|
||
} else {
|
||
alert('No se pudo descargar el PDF desde MinIO')
|
||
}
|
||
} else {
|
||
alert('No se encontró la URL del PDF')
|
||
}
|
||
}
|
||
} else {
|
||
alert('Error al generar PDF')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al generar PDF')
|
||
}
|
||
}}
|
||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition flex items-center gap-2"
|
||
>
|
||
<span>📄</span>
|
||
Exportar PDF
|
||
</button>
|
||
{user?.role === 'admin' && (
|
||
<>
|
||
<button
|
||
onClick={async () => {
|
||
if (confirm('¿Deseas inactivar esta inspección?')) {
|
||
setIsInactivating(true)
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/deactivate`, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
alert('Inspección inactivada correctamente')
|
||
onUpdate && onUpdate()
|
||
onClose()
|
||
} else {
|
||
alert('Error al inactivar la inspección')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al inactivar la inspección')
|
||
}
|
||
setIsInactivating(false)
|
||
}
|
||
}}
|
||
disabled={isInactivating}
|
||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50"
|
||
>
|
||
{isInactivating ? 'Inactivando...' : 'Inactivar'}
|
||
</button>
|
||
</>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modal de Historial de Auditoría */}
|
||
{showAuditLog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||
<div className="bg-purple-600 text-white p-6">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<h2 className="text-2xl font-bold">📜 Historial de Cambios</h2>
|
||
<p className="mt-1 opacity-90">Inspección #{inspection.id}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowAuditLog(false)}
|
||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{auditLogs.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<div className="text-4xl mb-3">📝</div>
|
||
<p className="text-gray-600">No hay cambios registrados en esta inspección</p>
|
||
<p className="text-sm text-gray-500 mt-2">
|
||
Los cambios realizados por administradores aparecerán aquí
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{auditLogs.map((log) => (
|
||
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">
|
||
{log.action === 'created' && '➕'}
|
||
{log.action === 'updated' && '✏️'}
|
||
{log.action === 'deleted' && '🗑️'}
|
||
{log.action === 'status_changed' && '🔄'}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="font-semibold text-gray-900">
|
||
{log.user_name || `Usuario #${log.user_id}`}
|
||
</span>
|
||
<span className="text-gray-400">•</span>
|
||
<span className="text-sm text-gray-500">
|
||
{new Date(log.created_at).toLocaleDateString('es-ES', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-sm">
|
||
<span className="text-gray-600">Acción: </span>
|
||
<span className="font-medium capitalize">{log.action}</span>
|
||
<span className="text-gray-400 mx-2">en</span>
|
||
<span className="font-medium">{log.entity_type}</span>
|
||
{log.answer_id && (
|
||
<span className="text-gray-500"> (Respuesta #{log.answer_id})</span>
|
||
)}
|
||
</div>
|
||
|
||
{log.field_name && (
|
||
<div className="mt-2 bg-gray-50 rounded p-3">
|
||
<div className="text-xs font-semibold text-gray-600 mb-2">
|
||
Campo modificado: <span className="text-purple-600">{log.field_name}</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||
<div className="bg-red-50 border border-red-200 rounded px-2 py-1 text-red-800">
|
||
{log.old_value || '(vacío)'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||
<div className="bg-green-50 border border-green-200 rounded px-2 py-1 text-green-800">
|
||
{log.new_value || '(vacío)'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{log.comment && (
|
||
<div className="mt-2 bg-yellow-50 border border-yellow-200 rounded p-2">
|
||
<div className="text-xs font-semibold text-yellow-800 mb-1">Motivo:</div>
|
||
<div className="text-sm text-yellow-900">{log.comment}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t p-4 bg-gray-50">
|
||
<button
|
||
onClick={() => setShowAuditLog(false)}
|
||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||
>
|
||
Cerrar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InspectionsTab({ inspections, user, onUpdate }) {
|
||
const [selectedInspection, setSelectedInspection] = useState(null)
|
||
|
||
if (inspections.length === 0) {
|
||
return (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">No hay inspecciones registradas</p>
|
||
<p className="text-sm text-gray-400 mt-2">Crea tu primera inspección</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-4">
|
||
{inspections.map((inspection) => (
|
||
<div key={inspection.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{inspection.vehicle_plate}
|
||
</h3>
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||
inspection.status === 'completed'
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-yellow-100 text-yellow-800'
|
||
}`}>
|
||
{inspection.status === 'completed' ? 'Completada' : 'Borrador'}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
{inspection.vehicle_brand} {inspection.vehicle_model} - {inspection.vehicle_km} km
|
||
</p>
|
||
<div className="flex gap-4 mt-3 text-sm">
|
||
<span className="text-gray-500">
|
||
OR: <strong>{inspection.or_number || 'N/A'}</strong>
|
||
</span>
|
||
<span className="text-gray-500">
|
||
Score: <strong>{inspection.score}/{inspection.max_score}</strong> ({inspection.percentage.toFixed(1)}%)
|
||
</span>
|
||
{inspection.flagged_items_count > 0 && (
|
||
<span className="text-red-600">
|
||
⚠️ <strong>{inspection.flagged_items_count}</strong> elementos señalados
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedInspection(inspection)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Ver Detalle
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Modal de Detalle */}
|
||
{selectedInspection && (
|
||
<InspectionDetailModal
|
||
inspection={selectedInspection}
|
||
user={user}
|
||
onClose={() => setSelectedInspection(null)}
|
||
onUpdate={onUpdate}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||
const [step, setStep] = useState(1) // 1: Vehicle Info, 2: Questions, 3: Signatures
|
||
const [loading, setLoading] = useState(false)
|
||
const [questions, setQuestions] = useState([])
|
||
const [inspectionId, setInspectionId] = useState(null)
|
||
|
||
// Form data for vehicle and client
|
||
const [vehicleData, setVehicleData] = useState({
|
||
vehicle_plate: '',
|
||
vehicle_brand: '',
|
||
vehicle_model: '',
|
||
vehicle_km: '',
|
||
client_name: '',
|
||
or_number: ''
|
||
})
|
||
|
||
// Answers data
|
||
const [answers, setAnswers] = useState({})
|
||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||
const [aiAnalyzing, setAiAnalyzing] = useState(false)
|
||
|
||
// Signature canvas
|
||
const mechanicSigRef = useRef(null)
|
||
|
||
// Load questions when modal opens
|
||
useEffect(() => {
|
||
const loadQuestions = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log('Loading questions for checklist:', checklist.id)
|
||
|
||
const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
console.log('Questions response status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const checklistData = await response.json()
|
||
const questionsData = checklistData.questions || []
|
||
console.log('Questions loaded:', questionsData.length, 'questions')
|
||
setQuestions(questionsData)
|
||
|
||
// Initialize answers object - empty values to force user interaction
|
||
const initialAnswers = {}
|
||
questionsData.forEach(q => {
|
||
initialAnswers[q.id] = {
|
||
value: '', // No default value - user must choose
|
||
observations: '',
|
||
photos: []
|
||
}
|
||
})
|
||
console.log('Initial answers:', initialAnswers)
|
||
setAnswers(initialAnswers)
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.error('Error loading questions:', errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading questions:', error)
|
||
}
|
||
}
|
||
|
||
loadQuestions()
|
||
}, [checklist.id])
|
||
|
||
// Step 1: Create inspection with vehicle data
|
||
const handleVehicleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log('Creating inspection with data:', vehicleData)
|
||
|
||
// Prepare data for API
|
||
const inspectionData = {
|
||
checklist_id: checklist.id,
|
||
vehicle_plate: vehicleData.vehicle_plate,
|
||
vehicle_brand: vehicleData.vehicle_brand || null,
|
||
vehicle_model: vehicleData.vehicle_model || null,
|
||
vehicle_km: vehicleData.vehicle_km ? parseInt(vehicleData.vehicle_km) : null,
|
||
client_name: vehicleData.client_name || null,
|
||
or_number: vehicleData.or_number || null
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/inspections`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(inspectionData)
|
||
})
|
||
|
||
console.log('Response status:', response.status)
|
||
|
||
if (response.ok) {
|
||
const inspection = await response.json()
|
||
console.log('Inspection created:', inspection)
|
||
setInspectionId(inspection.id)
|
||
|
||
// Only move to step 2 if questions are loaded
|
||
if (questions.length > 0) {
|
||
setStep(2)
|
||
} else {
|
||
alert('Error: El checklist no tiene preguntas configuradas')
|
||
}
|
||
} else {
|
||
const errorText = await response.text()
|
||
console.error('Error response:', errorText)
|
||
alert('Error al crear la inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al crear la inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Step 2: Auto-save answer when changed (non-blocking)
|
||
const saveAnswer = async (questionId) => {
|
||
if (!inspectionId) return
|
||
|
||
const question = questions.find(q => q.id === questionId)
|
||
const answer = answers[questionId]
|
||
|
||
if (!answer?.value && answer?.value !== '') return // Don't save if no value
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Determine status based on answer value
|
||
let status = 'ok'
|
||
if (question.type === 'pass_fail') {
|
||
status = answer.value === 'pass' ? 'ok' : 'critical'
|
||
} else if (question.type === 'good_bad') {
|
||
if (answer.value === 'good') status = 'ok'
|
||
else if (answer.value === 'regular') status = 'warning'
|
||
else if (answer.value === 'bad') status = 'critical'
|
||
}
|
||
|
||
// Submit answer
|
||
const answerData = {
|
||
inspection_id: inspectionId,
|
||
question_id: question.id,
|
||
answer_value: answer.value,
|
||
status: status,
|
||
comment: answer.observations || null,
|
||
is_flagged: status === 'critical'
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/answers`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(answerData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
const savedAnswer = await response.json()
|
||
|
||
// Upload photos if any
|
||
if (answer.photos.length > 0) {
|
||
for (const photoFile of answer.photos) {
|
||
const formData = new FormData()
|
||
formData.append('file', photoFile)
|
||
|
||
await fetch(`${API_URL}/api/answers/${savedAnswer.id}/upload`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
}
|
||
}
|
||
|
||
// Mark as saved
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[questionId]: { ...prev[questionId], saved: true }
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving answer:', error)
|
||
}
|
||
}
|
||
|
||
// Navigate between questions freely
|
||
const goToQuestion = (index) => {
|
||
setCurrentQuestionIndex(index)
|
||
}
|
||
|
||
// Validate all questions answered before completing
|
||
const validateAllAnswered = () => {
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const unanswered = visibleQuestions.filter(q => !answers[q.id]?.value)
|
||
return unanswered
|
||
}
|
||
|
||
// Get visible questions based on conditional logic
|
||
const getVisibleQuestions = () => {
|
||
return questions.filter(q => {
|
||
// If no parent, always visible
|
||
if (!q.parent_question_id) return true
|
||
|
||
// Check parent answer
|
||
const parentAnswer = answers[q.parent_question_id]
|
||
if (!parentAnswer) return false
|
||
|
||
// Show if parent answer matches trigger
|
||
return parentAnswer.value === q.show_if_answer
|
||
})
|
||
}
|
||
|
||
// Move to signatures step
|
||
const proceedToSignatures = () => {
|
||
const unanswered = validateAllAnswered()
|
||
if (unanswered.length > 0) {
|
||
alert(`⚠️ Faltan responder ${unanswered.length} pregunta(s). Por favor completa todas las preguntas antes de continuar.`)
|
||
// Go to first unanswered
|
||
const firstIndex = questions.findIndex(q => q.id === unanswered[0].id)
|
||
if (firstIndex >= 0) setCurrentQuestionIndex(firstIndex)
|
||
return
|
||
}
|
||
setStep(3)
|
||
}
|
||
|
||
// Step 3: Submit signatures and complete
|
||
const handleComplete = async () => {
|
||
setLoading(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
// Get mechanic signature as base64
|
||
const mechanicSig = mechanicSigRef.current ? mechanicSigRef.current.toDataURL() : null
|
||
|
||
// Update inspection with signature
|
||
const updateResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
signature_data: mechanicSig
|
||
})
|
||
})
|
||
|
||
console.log('Update inspection response:', updateResponse.status)
|
||
|
||
// Complete inspection
|
||
const completeResponse = await fetch(`${API_URL}/api/inspections/${inspectionId}/complete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
console.log('Complete inspection response:', completeResponse.status)
|
||
|
||
if (completeResponse.ok) {
|
||
const completedInspection = await completeResponse.json()
|
||
console.log('Inspection completed:', completedInspection)
|
||
alert(`✅ Inspección completada!\n\nPuntuación: ${completedInspection.score}/${completedInspection.max_score} (${completedInspection.percentage.toFixed(1)}%)\nElementos señalados: ${completedInspection.flagged_items_count}`)
|
||
onComplete()
|
||
onClose()
|
||
} else {
|
||
const errorText = await completeResponse.text()
|
||
console.error('Error completing inspection:', errorText)
|
||
alert('Error al completar inspección: ' + errorText)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al completar inspección: ' + error.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handlePhotoChange = async (questionId, files) => {
|
||
const filesArray = Array.from(files)
|
||
|
||
// Update photos immediately
|
||
setAnswers({
|
||
...answers,
|
||
[questionId]: {
|
||
...answers[questionId],
|
||
photos: filesArray
|
||
}
|
||
})
|
||
|
||
// If AI mode is assisted or full, analyze the photos
|
||
if ((checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && filesArray.length > 0) {
|
||
await analyzePhotosWithAI(questionId, filesArray)
|
||
}
|
||
}
|
||
|
||
const analyzePhotosWithAI = async (questionId, files) => {
|
||
const question = questions.find(q => q.id === questionId)
|
||
if (!question) return
|
||
|
||
setAiAnalyzing(true)
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
console.log(`🤖 Analizando ${files.length} foto(s) con IA para pregunta: ${question.text}`)
|
||
|
||
// Use custom AI prompt if available
|
||
if (question.ai_prompt) {
|
||
console.log(`📝 Usando prompt personalizado: ${question.ai_prompt.substring(0, 50)}...`)
|
||
}
|
||
|
||
// Analyze each photo
|
||
const analyses = []
|
||
for (const file of files) {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('question_id', question.id.toString())
|
||
|
||
// Include custom prompt if available
|
||
if (question.ai_prompt) {
|
||
formData.append('custom_prompt', question.ai_prompt)
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}/api/analyze-image`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
const result = await response.json()
|
||
|
||
// Check if AI analysis was successful
|
||
if (result.success && result.analysis) {
|
||
analyses.push(result)
|
||
console.log('✅ Análisis IA:', result)
|
||
} else {
|
||
console.warn('⚠️ Error en análisis IA:', result.error || result.message)
|
||
// Show user-friendly error
|
||
if (result.error && result.error.includes('No AI configuration')) {
|
||
alert('⚙️ Por favor configura tu API key en Configuración primero.')
|
||
}
|
||
}
|
||
} else {
|
||
console.warn('⚠️ Error HTTP en análisis IA:', response.status, await response.text())
|
||
}
|
||
}
|
||
|
||
if (analyses.length > 0) {
|
||
// Process analysis results
|
||
const firstResult = analyses[0]
|
||
const analysis = firstResult.analysis
|
||
let suggestedAnswer = null
|
||
let observationsText = ''
|
||
|
||
// Check if analysis is an object (structured JSON response)
|
||
if (typeof analysis === 'object' && analysis !== null) {
|
||
// Extract structured information
|
||
const status = analysis.status || 'ok'
|
||
const observations = analysis.observations || ''
|
||
const recommendation = analysis.recommendation || ''
|
||
const confidence = analysis.confidence || 0.7
|
||
|
||
// Build observations text
|
||
observationsText = `🤖 Análisis IA (${(confidence * 100).toFixed(0)}% confianza):\n${observations}`
|
||
if (recommendation) {
|
||
observationsText += `\n\n💡 Recomendación: ${recommendation}`
|
||
}
|
||
|
||
// Map status to answer based on question type
|
||
if (question.type === 'pass_fail') {
|
||
if (status === 'ok') {
|
||
suggestedAnswer = 'pass'
|
||
} else if (status === 'critical' || status === 'minor') {
|
||
suggestedAnswer = 'fail'
|
||
}
|
||
} else if (question.type === 'good_bad') {
|
||
if (status === 'ok') {
|
||
suggestedAnswer = 'good'
|
||
} else if (status === 'minor') {
|
||
suggestedAnswer = 'regular'
|
||
} else if (status === 'critical') {
|
||
suggestedAnswer = 'bad'
|
||
}
|
||
}
|
||
} else if (typeof analysis === 'string') {
|
||
// Fallback for plain text responses
|
||
observationsText = `🤖 Análisis IA:\n${analysis}`
|
||
const analysisLower = analysis.toLowerCase()
|
||
|
||
if (question.type === 'pass_fail') {
|
||
if (analysisLower.includes('pasa') || analysisLower.includes('correcto') || analysisLower.includes('bueno') || analysisLower.includes('ok')) {
|
||
suggestedAnswer = 'pass'
|
||
} else if (analysisLower.includes('falla') || analysisLower.includes('incorrecto') || analysisLower.includes('problema') || analysisLower.includes('critical')) {
|
||
suggestedAnswer = 'fail'
|
||
}
|
||
} else if (question.type === 'good_bad') {
|
||
if (analysisLower.includes('bueno') || analysisLower.includes('excelente')) {
|
||
suggestedAnswer = 'good'
|
||
} else if (analysisLower.includes('regular') || analysisLower.includes('aceptable')) {
|
||
suggestedAnswer = 'regular'
|
||
} else if (analysisLower.includes('malo') || analysisLower.includes('deficiente')) {
|
||
suggestedAnswer = 'bad'
|
||
}
|
||
}
|
||
}
|
||
|
||
// In FULL mode, auto-fill the answer
|
||
if (checklist.ai_mode === 'full' && suggestedAnswer) {
|
||
setAnswers({
|
||
...answers,
|
||
[questionId]: {
|
||
...answers[questionId],
|
||
value: suggestedAnswer,
|
||
observations: observationsText,
|
||
photos: files
|
||
}
|
||
})
|
||
console.log(`🤖 FULL MODE: Respuesta auto-completada con: ${suggestedAnswer}`)
|
||
}
|
||
// In ASSISTED mode, suggest in observations
|
||
else if (checklist.ai_mode === 'assisted') {
|
||
setAnswers({
|
||
...answers,
|
||
[questionId]: {
|
||
...answers[questionId],
|
||
observations: `${suggestedAnswer ? `[IA Sugiere: ${suggestedAnswer}]\n` : ''}${observationsText}`,
|
||
photos: files
|
||
}
|
||
})
|
||
console.log(`🤖 ASSISTED MODE: Sugerencia agregada a observaciones`)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error al analizar fotos con IA:', error)
|
||
// Don't block the user if AI fails
|
||
} finally {
|
||
setAiAnalyzing(false)
|
||
}
|
||
}
|
||
|
||
// Get visible questions based on conditional logic
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const currentQuestion = visibleQuestions[currentQuestionIndex]
|
||
|
||
// Barra de navegación de preguntas
|
||
// Paginación para la barra de preguntas
|
||
const QUESTIONS_PER_PAGE = 8;
|
||
const [questionPage, setQuestionPage] = useState(0);
|
||
const totalPages = Math.ceil(visibleQuestions.length / QUESTIONS_PER_PAGE);
|
||
const startIdx = questionPage * QUESTIONS_PER_PAGE;
|
||
const endIdx = startIdx + QUESTIONS_PER_PAGE;
|
||
const visibleBlock = visibleQuestions.slice(startIdx, endIdx);
|
||
|
||
const QuestionNavigator = () => (
|
||
<div className="flex items-center gap-2 mb-6 justify-center">
|
||
{/* Flecha izquierda */}
|
||
<button
|
||
onClick={() => setQuestionPage((p) => Math.max(0, p - 1))}
|
||
disabled={questionPage === 0}
|
||
className={`w-8 h-8 rounded-full border flex items-center justify-center text-xl font-bold transition-all select-none bg-gray-700 text-white border-gray-900 shadow-lg disabled:opacity-40 disabled:cursor-not-allowed`}
|
||
title="Anterior"
|
||
>
|
||
←
|
||
</button>
|
||
{/* Números de preguntas */}
|
||
{visibleBlock.map((q, idx) => {
|
||
const globalIdx = startIdx + idx;
|
||
const answered = answers[q.id]?.value;
|
||
let base = 'w-10 h-10 rounded-full border shadow-lg flex items-center justify-center text-lg font-bold transition-all select-none';
|
||
let style = '';
|
||
if (globalIdx === currentQuestionIndex) {
|
||
style = 'bg-blue-900 text-white border-blue-900 scale-110';
|
||
} else if (answered) {
|
||
style = 'bg-green-700 text-white border-green-800';
|
||
} else {
|
||
style = 'bg-gray-700 text-white border-gray-900';
|
||
}
|
||
return (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => goToQuestion(globalIdx)}
|
||
className={`${base} ${style} hover:bg-blue-700 hover:border-blue-700`}
|
||
title={`Pregunta ${globalIdx + 1}${answered ? ' (respondida)' : ''}`}
|
||
>
|
||
{globalIdx + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
{/* Flecha derecha */}
|
||
<button
|
||
onClick={() => setQuestionPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={questionPage >= totalPages - 1}
|
||
className={`w-8 h-8 rounded-full border flex items-center justify-center text-xl font-bold transition-all select-none bg-gray-700 text-white border-gray-900 shadow-lg disabled:opacity-40 disabled:cursor-not-allowed`}
|
||
title="Siguiente"
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h2 className="text-2xl font-bold text-gray-900">
|
||
Nueva Inspección: {checklist.name}
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* AI Mode Banner */}
|
||
{checklist.ai_mode !== 'off' && (
|
||
<div className={`mb-4 p-3 rounded-lg border ${
|
||
checklist.ai_mode === 'full'
|
||
? 'bg-purple-50 border-purple-200'
|
||
: 'bg-blue-50 border-blue-200'
|
||
}`}>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xl">🤖</span>
|
||
<div>
|
||
<p className={`text-sm font-medium ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-900' : 'text-blue-900'
|
||
}`}>
|
||
{checklist.ai_mode === 'full' ? 'Modo IA COMPLETO activado' : 'Modo IA ASISTIDO activado'}
|
||
</p>
|
||
<p className={`text-xs ${
|
||
checklist.ai_mode === 'full' ? 'text-purple-700' : 'text-blue-700'
|
||
}`}>
|
||
{checklist.ai_mode === 'full'
|
||
? 'La IA completará automáticamente las respuestas al subir fotos. Revisa y ajusta si es necesario.'
|
||
: 'La IA sugerirá respuestas en las observaciones al subir fotos.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress indicator */}
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
Paso {step} de 3
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{step === 1 && 'Datos del Vehículo'}
|
||
{step === 2 && (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${answered}/${visible.length} preguntas respondidas`
|
||
})()}
|
||
{step === 3 && 'Firmas'}
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||
style={{
|
||
width: step === 1 ? '33%' : step === 2 ? (() => {
|
||
const visible = getVisibleQuestions()
|
||
const answered = visible.filter(q => answers[q.id]?.value).length
|
||
return `${33 + (answered / visible.length) * 33}%`
|
||
})() : '100%'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 1: Vehicle Data */}
|
||
{step === 1 && (
|
||
<form onSubmit={handleVehicleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Placa del Vehículo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_plate}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_plate: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ABC-123"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Marca *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_brand}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_brand: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Toyota"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Modelo *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={vehicleData.vehicle_model}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_model: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Corolla"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Kilometraje
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={vehicleData.vehicle_km}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, vehicle_km: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="50000"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Número de OR
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.or_number}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, or_number: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="OR-001"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre del Cliente
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={vehicleData.client_name}
|
||
onChange={(e) => setVehicleData({ ...vehicleData, client_name: e.target.value })}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Juan Pérez"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||
>
|
||
{loading ? 'Creando...' : 'Continuar'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{/* Step 2: Questions */}
|
||
{step === 2 && questions.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Cargando preguntas...</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && questions.length > 0 && !currentQuestion && (
|
||
<div className="text-center py-12">
|
||
<p className="text-red-500">No hay más preguntas disponibles</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && currentQuestion && (
|
||
<div className="space-y-6">
|
||
{/* Barra de navegación de preguntas */}
|
||
<QuestionNavigator />
|
||
<div className="bg-gray-50 p-4 rounded-lg">
|
||
<div className="text-sm text-gray-600 mb-1">
|
||
Sección: <strong>{currentQuestion.section}</strong>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{currentQuestion.text}
|
||
</h3>
|
||
{currentQuestion.points > 0 && (
|
||
<div className="text-sm text-blue-600 mt-1">
|
||
Puntos: {currentQuestion.points}
|
||
</div>
|
||
)}
|
||
{console.log('Current question:', currentQuestion.id, 'Type:', currentQuestion.type, 'Answer:', answers[currentQuestion.id])}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{/* Answer input based on type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Respuesta *
|
||
</label>
|
||
|
||
{currentQuestion.type === 'pass_fail' && (
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
value="pass"
|
||
checked={answers[currentQuestion.id]?.value === 'pass'}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||
}))
|
||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||
}}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-green-600">✓ Pasa</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
value="fail"
|
||
checked={answers[currentQuestion.id]?.value === 'fail'}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||
}))
|
||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||
}}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-red-600">✗ Falla</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{currentQuestion.type === 'good_bad' && (
|
||
<select
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||
}))
|
||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||
}}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="">Seleccionar...</option>
|
||
<option value="good">Bueno</option>
|
||
<option value="regular">Regular</option>
|
||
<option value="bad">Malo</option>
|
||
</select>
|
||
)}
|
||
|
||
{currentQuestion.type === 'numeric' && (
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Ingrese un valor numérico"
|
||
/>
|
||
)}
|
||
|
||
{currentQuestion.type === 'status' && (
|
||
<select
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="">Seleccionar...</option>
|
||
<option value="ok">OK</option>
|
||
<option value="warning">Advertencia</option>
|
||
<option value="critical">Crítico</option>
|
||
</select>
|
||
)}
|
||
|
||
{currentQuestion.type === 'text' && (
|
||
<textarea
|
||
value={answers[currentQuestion.id]?.value}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="3"
|
||
placeholder="Ingrese su respuesta"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Observations */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Observaciones (opcional)
|
||
</label>
|
||
<textarea
|
||
value={answers[currentQuestion.id]?.observations}
|
||
onChange={(e) => setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: { ...prev[currentQuestion.id], observations: e.target.value }
|
||
}))}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
rows="2"
|
||
placeholder="Notas adicionales..."
|
||
/>
|
||
</div>
|
||
|
||
{/* Photos */}
|
||
{currentQuestion.allow_photos && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Fotografías (opcional)
|
||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||
<span className="ml-2 text-xs text-blue-600">
|
||
🤖 Análisis IA activado
|
||
</span>
|
||
)}
|
||
</label>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handlePhotoChange(currentQuestion.id, e.target.files)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
disabled={aiAnalyzing}
|
||
/>
|
||
|
||
{aiAnalyzing && (
|
||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||
<div className="flex items-center gap-2 text-blue-700">
|
||
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||
<span className="text-sm font-medium">Analizando imagen con IA...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{answers[currentQuestion.id]?.photos?.length > 0 && !aiAnalyzing && (
|
||
<div className="text-sm text-gray-600 mt-1">
|
||
{answers[currentQuestion.id].photos.length} foto(s) seleccionada(s)
|
||
{checklist.ai_mode === 'full' && answers[currentQuestion.id]?.value && (
|
||
<span className="ml-2 text-green-600">✓ Analizada</span>
|
||
)}
|
||
{checklist.ai_mode === 'assisted' && answers[currentQuestion.id]?.observations.includes('[IA Sugiere') && (
|
||
<span className="ml-2 text-blue-600">✓ Sugerencia generada</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (currentQuestionIndex > 0) {
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex - 1)
|
||
}
|
||
}}
|
||
disabled={currentQuestionIndex === 0}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
← Anterior
|
||
</button>
|
||
|
||
{currentQuestionIndex < getVisibleQuestions().length - 1 ? (
|
||
<button
|
||
onClick={() => {
|
||
saveAnswer(currentQuestion.id)
|
||
goToQuestion(currentQuestionIndex + 1)
|
||
}}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||
>
|
||
Siguiente →
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => {
|
||
saveAnswer(currentQuestion.id)
|
||
proceedToSignatures()
|
||
}}
|
||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||
>
|
||
Completar y Firmar →
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Answer status indicator */}
|
||
{answers[currentQuestion.id]?.value && (
|
||
<div className="text-sm text-green-600 mt-2 flex items-center gap-1">
|
||
<span>✓</span>
|
||
<span>Respuesta guardada automáticamente</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Signatures */}
|
||
{step === 3 && (
|
||
<div className="space-y-6">
|
||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||
<p className="text-green-800">
|
||
✓ Todas las preguntas han sido respondidas. Por favor, agregue su firma para completar la inspección.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Firma del Mecánico
|
||
</label>
|
||
<div className="border-2 border-gray-300 rounded-lg">
|
||
<SignatureCanvas
|
||
ref={mechanicSigRef}
|
||
canvasProps={{
|
||
className: 'w-full h-48 cursor-crosshair'
|
||
}}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => mechanicSigRef.current?.clear()}
|
||
className="mt-2 text-sm text-red-600 hover:text-red-700"
|
||
>
|
||
Limpiar Firma
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setStep(2)}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
← Volver a Preguntas
|
||
</button>
|
||
<button
|
||
onClick={handleComplete}
|
||
disabled={loading}
|
||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||
>
|
||
{loading ? 'Finalizando...' : '✓ Finalizar Inspección'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente de Gestión de Usuarios (Admin)
|
||
function UsersTab({ user }) {
|
||
const [users, setUsers] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [editingUser, setEditingUser] = useState(null)
|
||
const [showInactive, setShowInactive] = useState(false)
|
||
const [formData, setFormData] = useState({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
role: 'mechanic'
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadUsers()
|
||
}, [])
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/users', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setUsers(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading users:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateUser = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch('/api/users', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(formData)
|
||
})
|
||
|
||
if (response.ok) {
|
||
setShowCreateForm(false)
|
||
setFormData({ username: '', email: '', password: '', role: 'mechanic' })
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al crear usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating user:', error)
|
||
alert('Error al crear usuario')
|
||
}
|
||
}
|
||
|
||
const handleUpdateUser = async (userId, updates) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(updates)
|
||
})
|
||
|
||
if (response.ok) {
|
||
setEditingUser(null)
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al actualizar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating user:', error)
|
||
alert('Error al actualizar usuario')
|
||
}
|
||
}
|
||
|
||
const handleDeactivateUser = async (userId) => {
|
||
if (!confirm('¿Está seguro que desea desactivar este usuario?')) return
|
||
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}/deactivate`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al desactivar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deactivating user:', error)
|
||
alert('Error al desactivar usuario')
|
||
}
|
||
}
|
||
|
||
const handleActivateUser = async (userId) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${userId}/activate`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
})
|
||
|
||
if (response.ok) {
|
||
loadUsers()
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al activar usuario')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error activating user:', error)
|
||
alert('Error al activar usuario')
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-12">Cargando usuarios...</div>
|
||
}
|
||
|
||
const filteredUsers = showInactive ? users : users.filter(u => u.is_active)
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-2xl font-bold text-gray-800">Gestión de Usuarios</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowInactive(!showInactive)}
|
||
className={`px-4 py-2 rounded-lg transition ${
|
||
showInactive
|
||
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
{showInactive ? '👁️ Ocultar Inactivos' : '👁️🗨️ Mostrar Inactivos'}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
+ Nuevo Usuario
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lista de usuarios */}
|
||
<div className="grid gap-4">
|
||
{filteredUsers.map(u => (
|
||
<div key={u.id} className="bg-white rounded-lg shadow-md p-4">
|
||
<div className="flex justify-between items-start">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold">
|
||
{u.username.charAt(0).toUpperCase()}
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-gray-800">{u.username}</h3>
|
||
<p className="text-sm text-gray-500">{u.email}</p>
|
||
<div className="flex gap-2 mt-1">
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
u.role === 'admin'
|
||
? 'bg-purple-100 text-purple-700'
|
||
: u.role === 'asesor'
|
||
? 'bg-indigo-100 text-indigo-700'
|
||
: 'bg-blue-100 text-blue-700'
|
||
}`}>
|
||
{u.role === 'admin' ? '👑 Admin' : u.role === 'asesor' ? '📊 Asesor' : '🔧 Mecánico'}
|
||
</span>
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
u.is_active
|
||
? 'bg-green-100 text-green-700'
|
||
: 'bg-red-100 text-red-700'
|
||
}`}>
|
||
{u.is_active ? 'Activo' : 'Inactivo'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{user.role === 'admin' && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setEditingUser(u)}
|
||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition"
|
||
>
|
||
✏️ Editar
|
||
</button>
|
||
{u.id !== user.id && (
|
||
<>
|
||
{u.is_active ? (
|
||
<button
|
||
onClick={() => handleDeactivateUser(u.id)}
|
||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition"
|
||
>
|
||
Desactivar
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleActivateUser(u.id)}
|
||
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200 transition"
|
||
>
|
||
Activar
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Modal de creación */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||
<h3 className="text-xl font-bold mb-4">Nuevo Usuario</h3>
|
||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre de usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.username}
|
||
onChange={(e) => setFormData({...formData, username: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={formData.email}
|
||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Contraseña
|
||
</label>
|
||
<input
|
||
type="password"
|
||
required
|
||
value={formData.password}
|
||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Rol
|
||
</label>
|
||
<select
|
||
value={formData.role}
|
||
onChange={(e) => setFormData({...formData, role: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="mechanic">Mecánico</option>
|
||
<option value="admin">Administrador</option>
|
||
<option value="asesor">Asesor</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreateForm(false)
|
||
setFormData({ username: '', email: '', password: '', role: 'mechanic' })
|
||
}}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Crear Usuario
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal de edición */}
|
||
{editingUser && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||
<h3 className="text-xl font-bold mb-4">Editar Usuario</h3>
|
||
<form onSubmit={(e) => {
|
||
e.preventDefault()
|
||
const updates = {
|
||
username: editingUser.username,
|
||
email: editingUser.email,
|
||
role: editingUser.role
|
||
}
|
||
handleUpdateUser(editingUser.id, updates)
|
||
}} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Nombre de usuario
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={editingUser.username}
|
||
onChange={(e) => setEditingUser({...editingUser, username: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={editingUser.email}
|
||
onChange={(e) => setEditingUser({...editingUser, email: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Rol
|
||
</label>
|
||
<select
|
||
value={editingUser.role}
|
||
onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
>
|
||
<option value="mechanic">Mecánico</option>
|
||
<option value="admin">Administrador</option>
|
||
<option value="asesor">Asesor</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="border-t pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const newPassword = prompt('Ingrese la nueva contraseña:')
|
||
if (newPassword && newPassword.length >= 6) {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const response = await fetch(`/api/users/${editingUser.id}/password`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ new_password: newPassword })
|
||
})
|
||
|
||
if (response.ok) {
|
||
alert('Contraseña actualizada correctamente')
|
||
} else {
|
||
const error = await response.json()
|
||
alert(error.detail || 'Error al actualizar contraseña')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al actualizar contraseña')
|
||
}
|
||
} else if (newPassword !== null) {
|
||
alert('La contraseña debe tener al menos 6 caracteres')
|
||
}
|
||
}}
|
||
className="w-full px-4 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition"
|
||
>
|
||
🔑 Resetear Contraseña
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingUser(null)}
|
||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Guardar Cambios
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Componente de Reportes
|
||
function ReportsTab({ user }) {
|
||
const [dashboardData, setDashboardData] = useState(null)
|
||
const [inspections, setInspections] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [mechanics, setMechanics] = useState([])
|
||
const [checklists, setChecklists] = useState([])
|
||
const [filters, setFilters] = useState({
|
||
date: '',
|
||
mechanicId: '',
|
||
checklistId: '',
|
||
vehiclePlate: ''
|
||
})
|
||
const [appliedFilters, setAppliedFilters] = useState({
|
||
date: '',
|
||
mechanicId: '',
|
||
checklistId: '',
|
||
vehiclePlate: ''
|
||
})
|
||
|
||
const loadMechanics = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/users`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setMechanics(data.filter(u => u.role === 'mechanic' || u.role === 'mecanico'))
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading mechanics:', error)
|
||
}
|
||
}
|
||
|
||
const loadChecklists = async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/checklists`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setChecklists(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading checklists:', error)
|
||
}
|
||
}
|
||
|
||
const loadDashboard = async (filtersToApply) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
let url = `${API_URL}/api/reports/dashboard?`
|
||
if (filtersToApply.date) {
|
||
url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&`
|
||
}
|
||
if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&`
|
||
|
||
console.log('Dashboard URL:', url)
|
||
console.log('Filters applied:', filtersToApply)
|
||
|
||
const response = await fetch(url, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log('Dashboard data received:', data)
|
||
setDashboardData(data)
|
||
} else {
|
||
console.error('Dashboard request failed:', response.status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading dashboard:', error)
|
||
}
|
||
}
|
||
|
||
const loadInspections = async (filtersToApply) => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
|
||
let url = `${API_URL}/api/reports/inspections?`
|
||
if (filtersToApply.date) {
|
||
url += `start_date=${filtersToApply.date}&end_date=${filtersToApply.date}&`
|
||
}
|
||
if (filtersToApply.mechanicId) url += `mechanic_id=${filtersToApply.mechanicId}&`
|
||
|
||
console.log('Inspections URL:', url)
|
||
|
||
const response = await fetch(url, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
let data = await response.json()
|
||
console.log('Inspections data received:', data.length, 'items')
|
||
console.log('First inspection data:', data[0])
|
||
// Filtrar por checklist en el frontend
|
||
if (filtersToApply.checklistId) {
|
||
const filtered = data.filter(i => {
|
||
console.log('Comparing:', i.checklist_id, '===', parseInt(filtersToApply.checklistId))
|
||
return i.checklist_id === parseInt(filtersToApply.checklistId)
|
||
})
|
||
data = filtered
|
||
console.log('After checklist filter:', data.length, 'items')
|
||
}
|
||
// Filtrar por matrícula en el frontend
|
||
if (filtersToApply.vehiclePlate) {
|
||
const filtered = data.filter(i =>
|
||
i.vehicle_plate && i.vehicle_plate.toLowerCase().includes(filtersToApply.vehiclePlate.toLowerCase())
|
||
)
|
||
data = filtered
|
||
console.log('After plate filter:', data.length, 'items')
|
||
}
|
||
setInspections(data)
|
||
} else {
|
||
console.error('Inspections request failed:', response.status)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading inspections:', error)
|
||
}
|
||
}
|
||
|
||
const applyFilters = async () => {
|
||
setLoading(true)
|
||
setAppliedFilters(filters)
|
||
await Promise.all([loadDashboard(filters), loadInspections(filters)])
|
||
setLoading(false)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const loadInitialData = async () => {
|
||
setLoading(true)
|
||
// Cargar mecánicos y checklists primero
|
||
await Promise.all([loadMechanics(), loadChecklists()])
|
||
// Luego cargar datos sin filtros (filtros vacíos = todos los datos)
|
||
const emptyFilters = { date: '', mechanicId: '', checklistId: '', vehiclePlate: '' }
|
||
await Promise.all([loadDashboard(emptyFilters), loadInspections(emptyFilters)])
|
||
setLoading(false)
|
||
}
|
||
loadInitialData()
|
||
}, [])
|
||
|
||
if (loading) {
|
||
return <div className="text-center py-12">Cargando reportes...</div>
|
||
}
|
||
|
||
if (!dashboardData || !dashboardData.stats) {
|
||
return <div className="text-center py-12">No hay datos disponibles</div>
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Filtros */}
|
||
<div className="bg-white rounded-lg shadow p-4">
|
||
<h3 className="text-lg font-semibold mb-4">🔍 Filtros</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Fecha
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={filters.date}
|
||
onChange={(e) => setFilters({...filters, date: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
/>
|
||
</div>
|
||
{user.role === 'admin' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Mecánico
|
||
</label>
|
||
<select
|
||
value={filters.mechanicId}
|
||
onChange={(e) => setFilters({...filters, mechanicId: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
>
|
||
<option value="">Todos los mecánicos</option>
|
||
{mechanics.map(mechanic => (
|
||
<option key={mechanic.id} value={mechanic.id}>
|
||
{mechanic.full_name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Checklist
|
||
</label>
|
||
<select
|
||
value={filters.checklistId}
|
||
onChange={(e) => setFilters({...filters, checklistId: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
>
|
||
<option value="">Todos los checklists</option>
|
||
{checklists.map(checklist => (
|
||
<option key={checklist.id} value={checklist.id}>
|
||
{checklist.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Matrícula
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={filters.vehiclePlate}
|
||
onChange={(e) => setFilters({...filters, vehiclePlate: e.target.value})}
|
||
placeholder="Ej: ABC123"
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<button
|
||
onClick={applyFilters}
|
||
className="w-full px-4 py-2 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition"
|
||
>
|
||
Aplicar Filtros
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Métricas principales */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<div className="text-sm text-gray-500 mb-1">Total Inspecciones</div>
|
||
<div className="text-3xl font-bold text-indigo-600">
|
||
{dashboardData.stats?.total_inspections || 0}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<div className="text-sm text-gray-500 mb-1">Completadas</div>
|
||
<div className="text-3xl font-bold text-green-600">
|
||
{dashboardData.stats?.completed_inspections || 0}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<div className="text-sm text-gray-500 mb-1">Tasa de Completado</div>
|
||
<div className="text-3xl font-bold text-blue-600">
|
||
{(dashboardData.stats?.completion_rate || 0).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<div className="text-sm text-gray-500 mb-1">Promedio Score</div>
|
||
<div className="text-3xl font-bold text-purple-600">
|
||
{(dashboardData.stats?.avg_score || 0).toFixed(1)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ranking de Mecánicos */}
|
||
{user.role === 'admin' && dashboardData.mechanic_ranking?.length > 0 && (
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<h3 className="text-lg font-semibold mb-4">🏆 Ranking de Mecánicos</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full">
|
||
<thead>
|
||
<tr className="border-b">
|
||
<th className="text-left py-2 px-4">Posición</th>
|
||
<th className="text-left py-2 px-4">Mecánico</th>
|
||
<th className="text-right py-2 px-4">Inspecciones</th>
|
||
<th className="text-right py-2 px-4">Promedio</th>
|
||
<th className="text-right py-2 px-4">% Completado</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dashboardData.mechanic_ranking.map((mechanic, index) => (
|
||
<tr key={mechanic.mechanic_id} className="border-b hover:bg-gray-50">
|
||
<td className="py-2 px-4">
|
||
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
|
||
</td>
|
||
<td className="py-2 px-4 font-medium">{mechanic.mechanic_name}</td>
|
||
<td className="py-2 px-4 text-right">{mechanic.total_inspections}</td>
|
||
<td className="py-2 px-4 text-right">{mechanic.avg_score.toFixed(1)}</td>
|
||
<td className="py-2 px-4 text-right">{mechanic.completion_rate.toFixed(1)}%</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Estadísticas por Checklist */}
|
||
{dashboardData.checklist_stats?.length > 0 && (
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<h3 className="text-lg font-semibold mb-4">📋 Estadísticas por Checklist</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{dashboardData.checklist_stats.map((checklist) => (
|
||
<div key={checklist.checklist_id} className="border rounded-lg p-4">
|
||
<div className="font-medium text-gray-900 mb-2">{checklist.checklist_name}</div>
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
<div>Inspecciones: {checklist.total_inspections}</div>
|
||
<div>Promedio: {checklist.avg_score.toFixed(1)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Lista de Inspecciones */}
|
||
{inspections.length > 0 && (
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<h3 className="text-lg font-semibold mb-4">📝 Inspecciones Recientes</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full">
|
||
<thead>
|
||
<tr className="border-b">
|
||
<th className="text-left py-2 px-4">Fecha</th>
|
||
<th className="text-left py-2 px-4">Checklist</th>
|
||
<th className="text-left py-2 px-4">Mecánico</th>
|
||
<th className="text-left py-2 px-4">Placa</th>
|
||
<th className="text-right py-2 px-4">Score</th>
|
||
<th className="text-center py-2 px-4">Estado</th>
|
||
<th className="text-center py-2 px-4">Alertas</th>
|
||
<th className="text-center py-2 px-4">Acciones</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{inspections.slice(0, 20).map((inspection) => (
|
||
<tr key={inspection.id} className="border-b hover:bg-gray-50">
|
||
<td className="py-2 px-4">
|
||
{new Date(inspection.started_at).toLocaleDateString()}
|
||
</td>
|
||
<td className="py-2 px-4">{inspection.checklist_name}</td>
|
||
<td className="py-2 px-4">{inspection.mechanic_name}</td>
|
||
<td className="py-2 px-4 font-mono">{inspection.vehicle_plate}</td>
|
||
<td className="py-2 px-4 text-right font-medium">{inspection.score}</td>
|
||
<td className="py-2 px-4 text-center">
|
||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||
inspection.status === 'completed'
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-yellow-100 text-yellow-800'
|
||
}`}>
|
||
{inspection.status === 'completed' ? 'Completada' : 'Pendiente'}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 px-4 text-center">
|
||
{inspection.flagged_items > 0 && (
|
||
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 rounded-full">
|
||
🚩 {inspection.flagged_items}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 px-4 text-center">
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
const token = localStorage.getItem('token')
|
||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/pdf`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
})
|
||
|
||
if (response.ok) {
|
||
const blob = await response.blob()
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `inspeccion_${inspection.id}_${inspection.vehicle_plate || 'sin-patente'}.pdf`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(url)
|
||
} else {
|
||
alert('Error al generar PDF')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error)
|
||
alert('Error al generar PDF')
|
||
}
|
||
}}
|
||
className="px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition"
|
||
title="Exportar PDF"
|
||
>
|
||
📄
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
export default App
|