Registro y elevación de componentes base (ADN, Scripts, Servicios)

This commit is contained in:
Ricardo Monla
2026-02-15 20:37:53 -03:00
parent 9e9bf62c68
commit c865a7772b
25279 changed files with 3354766 additions and 0 deletions
@@ -0,0 +1,692 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
interface CalendarEvent {
id: string
title: string
start: string
end: string
backgroundColor: string
borderColor: string
textColor: string
extendedProps: {
type: string
technician: string
technicianName: string
resource: string
resourceName: string
description: string
subtasks: Array<{
title: string
time: string
technician: string
}>
reminders: string[]
}
}
interface CalendarStats {
events: number
tasks: number
activeTechnicians: number
busyResources: number
}
const Calendario = () => {
const { user } = useAuthStore()
const [calendar, setCalendar] = useState<any>(null)
const [currentEvents, setCurrentEvents] = useState<CalendarEvent[]>([])
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
const [stats, setStats] = useState<CalendarStats>({
events: 0,
tasks: 0,
activeTechnicians: 0,
busyResources: 0
})
const [showEventModal, setShowEventModal] = useState(false)
const [showDetailsModal, setShowDetailsModal] = useState(false)
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
initializeCalendar()
return () => {
if (calendar) {
calendar.destroy()
}
}
}, [])
useEffect(() => {
if (calendar) {
loadCalendarData()
}
}, [calendar])
const initializeCalendar = () => {
// Load FullCalendar dynamically
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'
script.onload = () => {
setTimeout(() => {
const calendarEl = document.getElementById('calendar')
if (calendarEl && (window as any).FullCalendar) {
const cal = new (window as any).FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
locale: 'es',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
buttonText: {
today: 'Hoy',
month: 'Mes',
week: 'Semana',
day: 'Día',
list: 'Lista'
},
height: 'auto',
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
eventDisplay: 'block',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
meridiem: false
},
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
meridiem: false
},
nowIndicator: true,
businessHours: {
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '08:00',
endTime: '18:00'
},
eventClick: function(info: any) {
showEventDetails(info.event)
},
select: function(info: any) {
openNewEventModal(info.start, info.end)
},
eventDrop: function(info: any) {
updateEventTime(info.event)
},
eventResize: function(info: any) {
updateEventTime(info.event)
},
datesSet: function(dateInfo: any) {
updateMonthStatistics()
}
})
cal.render()
setCalendar(cal)
setLoading(false)
} else {
console.error('FullCalendar not loaded or calendar element not found')
setLoading(false)
}
}, 1000) // Wait for script to fully load
}
script.onerror = () => {
console.error('Failed to load FullCalendar script')
setLoading(false)
}
document.head.appendChild(script)
}
const loadCalendarData = () => {
// Mock data - in production this would come from API
const mockEvents: CalendarEvent[] = [
{
id: '1',
title: 'Preparar los equipos en el Auditorio con música y proyector',
start: '2025-11-15T14:00:00',
end: '2025-11-15T16:00:00',
backgroundColor: '#17a2b8',
borderColor: '#17a2b8',
textColor: '#ffffff',
extendedProps: {
type: 'event',
technician: 'jgarcia',
technicianName: 'Juan García',
resource: 'auditorio',
resourceName: 'Auditorio',
description: 'Evento especial de capacitación técnica',
subtasks: [
{
title: 'Hacer pruebas de sonido y video 1 hora antes',
time: '2025-11-15T13:00:00',
technician: 'jgarcia'
},
{
title: 'Verificar conectividad de proyectores 30 minutos antes',
time: '2025-11-15T13:30:00',
technician: 'mrodriguez'
}
],
reminders: ['1h', '30m']
}
},
{
id: '2',
title: 'Mantenimiento preventivo de servidores',
start: '2025-11-16T09:00:00',
end: '2025-11-16T12:00:00',
backgroundColor: '#28a745',
borderColor: '#28a745',
textColor: '#ffffff',
extendedProps: {
type: 'maintenance',
technician: 'clopez',
technicianName: 'Carlos López',
resource: 'computadoras',
resourceName: 'Sala de Servidores',
description: 'Mantenimiento rutinario de equipos',
subtasks: [],
reminders: ['1h']
}
},
{
id: '3',
title: 'Capacitación en ciberseguridad',
start: '2025-11-18T10:00:00',
end: '2025-11-18T12:00:00',
backgroundColor: '#ffc107',
borderColor: '#ffc107',
textColor: '#000000',
extendedProps: {
type: 'training',
technician: 'amartinez',
technicianName: 'Ana Martínez',
resource: 'auditorio',
resourceName: 'Auditorio',
description: 'Sesión de capacitación para todo el equipo',
subtasks: [],
reminders: ['1h', '15m']
}
},
{
id: '4',
title: 'Reunión de coordinación semanal',
start: '2025-11-17T08:30:00',
end: '2025-11-17T09:30:00',
backgroundColor: '#6c757d',
borderColor: '#6c757d',
textColor: '#ffffff',
extendedProps: {
type: 'meeting',
technician: 'rmartinez',
technicianName: 'Roberto Martínez',
resource: 'auditorio',
resourceName: 'Sala de Reuniones',
description: 'Reunión semanal de coordinación del equipo DTIC',
subtasks: [],
reminders: ['15m']
}
}
]
setCurrentEvents(mockEvents)
// Add events to calendar if it's initialized
if (calendar) {
calendar.addEventSource(mockEvents)
}
updateUpcomingEvents(mockEvents)
updateMonthStatistics()
}
const updateUpcomingEvents = (events: CalendarEvent[] = currentEvents) => {
const now = new Date()
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
const upcoming = events
.filter(event => {
const eventStart = new Date(event.start)
return eventStart >= now && eventStart <= nextWeek
})
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, 6)
setUpcomingEvents(upcoming)
}
const updateMonthStatistics = () => {
if (!calendar) return
const view = calendar.view
const start = view.activeStart
const end = view.activeEnd
const monthEvents = currentEvents.filter(event => {
const eventStart = new Date(event.start)
return eventStart >= start && eventStart < end
})
const technicians = new Set()
const resources = new Set()
monthEvents.forEach(event => {
technicians.add(event.extendedProps.technician)
if (event.extendedProps.resource) {
resources.add(event.extendedProps.resource)
}
})
const totalTasks = monthEvents.reduce((sum, event) => {
return sum + (event.extendedProps.subtasks ? event.extendedProps.subtasks.length : 0)
}, 0)
setStats({
events: monthEvents.length,
tasks: totalTasks,
activeTechnicians: technicians.size,
busyResources: resources.size
})
}
const showEventDetails = (event: any) => {
const calendarEvent = currentEvents.find(e => e.id === event.id)
if (calendarEvent) {
setSelectedEvent(calendarEvent)
setShowDetailsModal(true)
}
}
const openNewEventModal = (start: Date, end: Date) => {
// Pre-fill start and end times
const startInput = document.getElementById('eventStart') as HTMLInputElement
const endInput = document.getElementById('eventEnd') as HTMLInputElement
if (startInput && endInput) {
startInput.value = formatDateTime(start)
endInput.value = formatDateTime(end)
}
setShowEventModal(true)
}
const updateEventTime = (event: any) => {
console.log('Event time updated:', event.id, event.start, event.end)
// In production, this would update the backend
}
const formatDateTime = (date: Date) => {
return date.toISOString().slice(0, 16)
}
const getEventTypeIcon = (type: string) => {
const icons: { [key: string]: string } = {
'maintenance': 'fas fa-wrench',
'event': 'fas fa-calendar-star',
'training': 'fas fa-graduation-cap',
'meeting': 'fas fa-users'
}
return icons[type] || 'fas fa-calendar'
}
const handleViewChange = (view: string) => {
if (calendar) {
calendar.changeView(view)
}
}
const handleTechnicianFilter = (technician: string) => {
if (!calendar) return
calendar.getEvents().forEach((event: any) => {
const show = !technician || event.extendedProps.technician === technician
event.setProp('display', show ? 'auto' : 'none')
})
}
const handleResourceFilter = (resource: string) => {
if (!calendar) return
calendar.getEvents().forEach((event: any) => {
const show = !resource || event.extendedProps.resource === resource
event.setProp('display', show ? 'auto' : 'none')
})
}
const clearFilters = () => {
if (!calendar) return
const technicianSelect = document.getElementById('technicianFilter') as HTMLSelectElement
const resourceSelect = document.getElementById('resourceFilter') as HTMLSelectElement
if (technicianSelect) technicianSelect.value = ''
if (resourceSelect) resourceSelect.value = ''
calendar.getEvents().forEach((event: any) => {
event.setProp('display', 'auto')
})
}
const goToToday = () => {
if (calendar) {
calendar.today()
}
}
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
<p className="mt-3">Cargando calendario...</p>
</div>
</div>
)
}
return (
<div className="container mt-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center mb-3">
<i className="fas fa-calendar-alt fa-2x text-primary me-3"></i>
<div>
<h1 className="h3 mb-0">Calendario Interactivo</h1>
<p className="text-muted mb-0">Gestión de eventos y programaciones del sistema</p>
</div>
</div>
</div>
</div>
{/* Calendar Filters and Controls */}
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-body">
<div className="row g-3">
<div className="col-md-3">
<select className="form-select" id="calendarView" onChange={(e) => handleViewChange(e.target.value)}>
<option value="dayGridMonth">Vista Mensual</option>
<option value="timeGridWeek">Vista Semanal</option>
<option value="timeGridDay">Vista Diaria</option>
<option value="listWeek">Lista Semanal</option>
</select>
</div>
<div className="col-md-3">
<select className="form-select" id="technicianFilter" onChange={(e) => handleTechnicianFilter(e.target.value)}>
<option value="">Todos los técnicos</option>
<option value="jgarcia">Juan García</option>
<option value="mrodriguez">María Rodríguez</option>
<option value="clopez">Carlos López</option>
<option value="amartinez">Ana Martínez</option>
<option value="rmartinez">Roberto Martínez</option>
</select>
</div>
<div className="col-md-3">
<select className="form-select" id="resourceFilter" onChange={(e) => handleResourceFilter(e.target.value)}>
<option value="">Todos los recursos</option>
<option value="auditorio">Auditorio</option>
<option value="proyector">Proyectores</option>
<option value="computadoras">Computadoras</option>
<option value="redes">Equipos de Red</option>
</select>
</div>
<div className="col-md-3">
<div className="d-flex gap-2">
<button className="btn btn-outline-primary flex-fill" onClick={goToToday}>Hoy</button>
<button className="btn btn-outline-secondary flex-fill" onClick={clearFilters}>Limpiar</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Calendar Container */}
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-body">
<div id="calendar"></div>
</div>
</div>
</div>
</div>
{/* Upcoming Events Sidebar */}
<div className="row mt-4">
<div className="col-md-8">
<div className="card">
<div className="card-header">
<h5 className="mb-0">Próximos Eventos</h5>
</div>
<div className="card-body">
<div className="list-group list-group-flush">
{upcomingEvents.length > 0 ? (
upcomingEvents.map(event => (
<div key={event.id} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{event.title}</h6>
<small className="text-muted">
{new Date(event.start).toLocaleString('es-AR', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</small>
</div>
<p className="mb-1">{event.extendedProps.technicianName}</p>
<small className="text-muted">{event.extendedProps.resourceName}</small>
</div>
))
) : (
<div className="text-center text-muted py-4">
<i className="fas fa-calendar-check fa-3x mb-3"></i>
<p>No hay eventos próximos programados</p>
</div>
)}
</div>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card">
<div className="card-header">
<h5 className="mb-0">Estadísticas del Mes</h5>
</div>
<div className="card-body">
<div className="row text-center">
<div className="col-6">
<div className="h4 text-primary mb-1" id="monthEvents">{stats.events}</div>
<small className="text-muted">Eventos</small>
</div>
<div className="col-6">
<div className="h4 text-success mb-1" id="monthTasks">{stats.tasks}</div>
<small className="text-muted">Tareas</small>
</div>
</div>
<hr />
<div className="row text-center">
<div className="col-6">
<div className="h4 text-info mb-1" id="activeTechnicians">{stats.activeTechnicians}</div>
<small className="text-muted">Técnicos Activos</small>
</div>
<div className="col-6">
<div className="h4 text-warning mb-1" id="busyResources">{stats.busyResources}</div>
<small className="text-muted">Recursos Ocupados</small>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Add Event Modal */}
{showEventModal && (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="fas fa-plus me-2"></i>Nuevo Evento
</h5>
<button type="button" className="btn-close" onClick={() => setShowEventModal(false)}></button>
</div>
<div className="modal-body">
<form id="addEventForm">
<div className="row">
<div className="col-md-8">
<div className="mb-3">
<label className="form-label">Título del Evento *</label>
<input type="text" className="form-control" id="eventTitle" required />
</div>
</div>
<div className="col-md-4">
<div className="mb-3">
<label className="form-label">Tipo *</label>
<select className="form-select" id="eventType" required>
<option value="">Seleccionar...</option>
<option value="maintenance">Mantenimiento</option>
<option value="event">Evento Especial</option>
<option value="training">Capacitación</option>
<option value="meeting">Reunión</option>
<option value="other">Otro</option>
</select>
</div>
</div>
</div>
<div className="mb-3">
<label className="form-label">Descripción</label>
<textarea className="form-control" id="eventDescription" rows={3}></textarea>
</div>
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Fecha y Hora de Inicio *</label>
<input type="datetime-local" className="form-control" id="eventStart" required />
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Fecha y Hora de Fin *</label>
<input type="datetime-local" className="form-control" id="eventEnd" required />
</div>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Técnico Asignado *</label>
<select className="form-select" id="eventTechnician" required>
<option value="">Seleccionar técnico...</option>
<option value="jgarcia">Juan García - Desarrollador</option>
<option value="mrodriguez">María Rodríguez - Analista</option>
<option value="clopez">Carlos López - Diseñador</option>
<option value="amartinez">Ana Martínez - Tester</option>
<option value="rmartinez">Roberto Martínez - Administrador</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Recurso Principal</label>
<select className="form-select" id="eventResource">
<option value="">Seleccionar recurso...</option>
<option value="auditorio">Auditorio</option>
<option value="proyector">Proyector Principal</option>
<option value="computadoras">Sala de Computadoras</option>
<option value="redes">Equipos de Red</option>
</select>
</div>
</div>
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowEventModal(false)}>Cancelar</button>
<button type="button" className="btn btn-info" onClick={() => console.log('Save event')}>
<i className="fas fa-save me-2"></i>Guardar Evento
</button>
</div>
</div>
</div>
</div>
)}
{/* Event Details Modal */}
{showDetailsModal && selectedEvent && (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="fas fa-info-circle me-2"></i>Detalles del Evento
</h5>
<button type="button" className="btn-close" onClick={() => setShowDetailsModal(false)}></button>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-8">
<h4>{selectedEvent.title}</h4>
<p className="text-muted">
{selectedEvent.extendedProps.type === 'maintenance' ? 'Mantenimiento' :
selectedEvent.extendedProps.type === 'event' ? 'Evento Especial' :
selectedEvent.extendedProps.type === 'training' ? 'Capacitación' :
selectedEvent.extendedProps.type === 'meeting' ? 'Reunión' : 'Evento'}
</p>
<hr />
<p><strong>Fecha y Hora:</strong> {new Date(selectedEvent.start).toLocaleString('es-AR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} - {new Date(selectedEvent.end).toLocaleString('es-AR', {
hour: '2-digit',
minute: '2-digit'
})}</p>
<p><strong>Técnico Asignado:</strong> {selectedEvent.extendedProps.technicianName}</p>
<p><strong>Recurso:</strong> {selectedEvent.extendedProps.resourceName}</p>
{selectedEvent.extendedProps.description && (
<p><strong>Descripción:</strong> {selectedEvent.extendedProps.description}</p>
)}
</div>
<div className="col-md-4">
<div className="card">
<div className="card-body text-center">
<i className="fas fa-clock fa-3x text-info mb-3"></i>
<h6>Próximo Recordatorio</h6>
<p className="mb-0">15 minutos antes</p>
</div>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowDetailsModal(false)}>Cerrar</button>
<button type="button" className="btn btn-warning">
<i className="fas fa-edit me-2"></i>Editar
</button>
<button type="button" className="btn btn-danger">
<i className="fas fa-trash me-2"></i>Eliminar
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default Calendario
@@ -0,0 +1,634 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
interface DashboardStats {
tecnicos: {
total: number
activos: number
inactivos: number
administradores: number
}
tareas: {
total: number
pendientes: number
enProgreso: number
completadas: number
canceladas: number
}
recursos: {
total: number
disponibles: number
asignados: number
mantenimiento: number
}
usuarios: {
total: number
conRecursos: number
sinRecursos: number
}
}
interface UpcomingEvent {
id: string
title: string
start: string
end: string
type: string
technician: string
technicianName: string
resource: string
resourceName: string
description: string
}
interface RecentActivity {
id: string
timestamp: string
title: string
description: string
type: string
}
const Dashboard = () => {
const { user } = useAuthStore()
const [stats, setStats] = useState<DashboardStats>({
tecnicos: { total: 0, activos: 0, inactivos: 0, administradores: 0 },
tareas: { total: 0, pendientes: 0, enProgreso: 0, completadas: 0, canceladas: 0 },
recursos: { total: 0, disponibles: 0, asignados: 0, mantenimiento: 0 },
usuarios: { total: 0, conRecursos: 0, sinRecursos: 0 }
})
const [upcomingEvents, setUpcomingEvents] = useState<UpcomingEvent[]>([])
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([])
const [currentDate, setCurrentDate] = useState('')
const [currentTime, setCurrentTime] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
loadDashboardData()
updateDateTime()
const timeInterval = setInterval(updateDateTime, 1000)
return () => clearInterval(timeInterval)
}, [])
const loadDashboardData = async () => {
try {
setLoading(true)
// Load data from all entities
const [tecnicosRes, tareasRes, recursosRes, usuariosRes] = await Promise.all([
fetch('/api/tecnicos'),
fetch('/api/tareas'),
fetch('/api/recursos'),
fetch('/api/usuarios_relacionados')
])
const tecnicos = tecnicosRes.ok ? await tecnicosRes.json() : []
const tareas = tareasRes.ok ? await tareasRes.json() : []
const recursos = recursosRes.ok ? await recursosRes.json() : []
const usuarios = usuariosRes.ok ? await usuariosRes.json() : []
// Calculate statistics
const tecnicosArray = Array.isArray(tecnicos) ? tecnicos : []
const tareasArray = Array.isArray(tareas) ? tareas : []
const recursosArray = Array.isArray(recursos) ? recursos : []
const usuariosArray = Array.isArray(usuarios) ? usuarios : []
const tecnicosStats = {
total: tecnicosArray.length,
activos: tecnicosArray.filter((t: any) => t.is_active).length,
inactivos: tecnicosArray.filter((t: any) => !t.is_active).length,
administradores: tecnicosArray.filter((t: any) => t.role === 'admin').length
}
const tareasStats = {
total: tareasArray.length,
pendientes: tareasArray.filter((t: any) => t.status === 'pending').length,
enProgreso: tareasArray.filter((t: any) => t.status === 'in_progress').length,
completadas: tareasArray.filter((t: any) => t.status === 'completed').length,
canceladas: tareasArray.filter((t: any) => t.status === 'cancelled').length
}
const recursosStats = {
total: recursosArray.length,
disponibles: recursosArray.filter((r: any) => r.status === 'available').length,
asignados: recursosArray.filter((r: any) => r.status === 'assigned').length,
mantenimiento: recursosArray.filter((r: any) => r.status === 'maintenance').length
}
const usuariosStats = {
total: usuariosArray.length,
conRecursos: usuariosArray.filter((u: any) => (u.assigned_resources_count || 0) > 0).length,
sinRecursos: usuariosArray.filter((u: any) => (u.assigned_resources_count || 0) === 0).length
}
setStats({
tecnicos: tecnicosStats,
tareas: tareasStats,
recursos: recursosStats,
usuarios: usuariosStats
})
// Generate mock upcoming events (in production, this would come from a dedicated events API)
const mockEvents: UpcomingEvent[] = [
{
id: '1',
title: 'Preparar los equipos en el Auditorio con música y proyector',
start: '2025-11-15T14:00:00',
end: '2025-11-15T16:00:00',
type: 'event',
technician: 'jgarcia',
technicianName: 'Juan García',
resource: 'auditorio',
resourceName: 'Auditorio',
description: 'Evento especial de capacitación técnica'
},
{
id: '2',
title: 'Mantenimiento preventivo de servidores',
start: '2025-11-16T09:00:00',
end: '2025-11-16T12:00:00',
type: 'maintenance',
technician: 'clopez',
technicianName: 'Carlos López',
resource: 'servidores',
resourceName: 'Sala de Servidores',
description: 'Mantenimiento rutinario de equipos'
},
{
id: '3',
title: 'Capacitación en ciberseguridad',
start: '2025-11-18T10:00:00',
end: '2025-11-18T12:00:00',
type: 'training',
technician: 'amartinez',
technicianName: 'Ana Martínez',
resource: 'auditorio',
resourceName: 'Auditorio',
description: 'Sesión de capacitación para todo el equipo'
}
]
setUpcomingEvents(mockEvents)
// Generate mock recent activity
const mockActivity: RecentActivity[] = [
{
id: '1',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
title: 'Nueva tarea completada',
description: 'Implementar API de autenticación finalizada por María Rodríguez',
type: 'task'
},
{
id: '2',
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
title: 'Recurso relacionado',
description: 'Laptop Dell Latitude 5420 relacionada con Juan García',
type: 'resource'
},
{
id: '3',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
title: 'Usuario registrado',
description: 'Ana Martínez se unió al sistema como Visualizador',
type: 'user'
}
]
setRecentActivity(mockActivity)
} catch (error) {
console.error('Error loading dashboard data:', error)
} finally {
setLoading(false)
}
}
const updateDateTime = () => {
const now = new Date()
// Formatear fecha en español de Argentina
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}
setCurrentDate(now.toLocaleDateString('es-AR', dateOptions))
// Formatear hora
const timeOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}
setCurrentTime(now.toLocaleTimeString('es-AR', timeOptions))
}
const getEventTypeIcon = (type: string) => {
const icons: { [key: string]: string } = {
'maintenance': 'fas fa-wrench',
'event': 'fas fa-calendar-star',
'training': 'fas fa-graduation-cap',
'meeting': 'fas fa-users'
}
return icons[type] || 'fas fa-calendar'
}
const getActivityTypeIcon = (type: string) => {
const icons: { [key: string]: string } = {
'maintenance': 'fas fa-wrench',
'task': 'fas fa-tasks',
'user': 'fas fa-user-plus',
'training': 'fas fa-graduation-cap',
'system': 'fas fa-server',
'meeting': 'fas fa-users',
'resource': 'fas fa-box'
}
return icons[type] || 'fas fa-info-circle'
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('es-AR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
<p className="mt-3">Cargando dashboard...</p>
</div>
</div>
)
}
return (
<div className="container mt-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center mb-3">
<i className="fas fa-chart-line fa-2x text-primary me-3"></i>
<div>
<h1 className="h3 mb-0">Dashboard</h1>
<p className="text-muted mb-0">Panel de control principal del sistema</p>
</div>
</div>
</div>
</div>
{/* Statistics Cards */}
{user && (
<div className="row mb-4">
<div className="col-md-3 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-clock text-warning me-2"></i>
Tareas Pendientes
</div>
<div className="card-body text-center">
<div className="card-text task-pending" id="pending-tasks">{stats.tareas.pendientes}</div>
<small className="text-muted">Esperando asignación</small>
</div>
</div>
</div>
<div className="col-md-3 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-play-circle text-info me-2"></i>
En Progreso
</div>
<div className="card-body text-center">
<div className="card-text task-in-progress" id="in-progress-tasks">{stats.tareas.enProgreso}</div>
<small className="text-muted">Actualmente trabajando</small>
</div>
</div>
</div>
<div className="col-md-3 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-check-circle text-success me-2"></i>
Completadas
</div>
<div className="card-body text-center">
<div className="card-text task-completed" id="completed-tasks">{stats.tareas.completadas}</div>
<small className="text-muted">Esta semana</small>
</div>
</div>
</div>
<div className="col-md-3 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-times-circle text-danger me-2"></i>
Canceladas
</div>
<div className="card-body text-center">
<div className="card-text task-cancelled" id="cancelled-tasks">{stats.tareas.canceladas}</div>
<small className="text-muted">Esta semana</small>
</div>
</div>
</div>
</div>
)}
{!user && (
<div className="row mb-4">
<div className="col-md-4 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-users text-primary me-2"></i>
Gestión de Técnicos
</div>
<div className="card-body text-center">
<div className="card-text">
<i className="fas fa-user-cog fa-2x text-primary mb-3"></i>
<p>Administra el personal técnico del DTIC</p>
</div>
</div>
</div>
</div>
<div className="col-md-4 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-tasks text-success me-2"></i>
Control de Tareas
</div>
<div className="card-body text-center">
<div className="card-text">
<i className="fas fa-clipboard-list fa-2x text-success mb-3"></i>
<p>Gestiona tareas y proyectos del departamento</p>
</div>
</div>
</div>
</div>
<div className="col-md-4 mb-3">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-boxes text-info me-2"></i>
Inventario de Recursos
</div>
<div className="card-body text-center">
<div className="card-text">
<i className="fas fa-server fa-2x text-info mb-3"></i>
<p>Controla equipos y recursos tecnológicos</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Recent Activity & Upcoming Events */}
<div className="row">
{/* Recent Tasks */}
<div className="col-md-6 mb-4">
<div className="card">
<div className="card-header">
<i className="fas fa-history me-2"></i>
Actividad Reciente
</div>
<div className="card-body">
{recentActivity.length > 0 ? (
<div className="activity-list">
{recentActivity.map(activity => (
<div key={activity.id} className="activity-item mb-3 p-2 border-start border-primary border-3">
<div className="d-flex align-items-start">
<i className={`${getActivityTypeIcon(activity.type)} me-2 mt-1`}></i>
<div className="flex-grow-1">
<small className="text-muted">{formatDate(activity.timestamp)}</small>
<div className="fw-bold">{activity.title}</div>
<small>{activity.description}</small>
</div>
</div>
</div>
))}
</div>
) : (
<div id="recent-activity" className="text-center text-muted py-4">
<i className="fas fa-inbox fa-3x mb-3"></i>
<p>No hay actividad reciente para mostrar.</p>
<small>Las actividades aparecerán aquí una vez que se implemente el backend.</small>
</div>
)}
</div>
</div>
</div>
{/* Upcoming Events */}
<div className="col-md-6 mb-4">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<i className="fas fa-calendar-check me-2"></i>
Próximos Eventos
</div>
<a href="/calendario" className="btn btn-sm btn-outline-primary">
<i className="fas fa-external-link-alt"></i>
</a>
</div>
<div className="card-body">
<div id="dashboard-upcoming-events">
{upcomingEvents.length > 0 ? (
upcomingEvents.slice(0, 4).map(event => (
<div key={event.id} className="dashboard-upcoming-event mb-3 p-2 border rounded">
<div className="event-title">
<i className={`${getEventTypeIcon(event.type)} me-2`}></i>{event.title}
</div>
<div className="event-time">
{new Date(event.start).toLocaleString('es-AR', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div className="event-details">
<small><strong>{event.technicianName}</strong> {event.resourceName}</small>
</div>
</div>
))
) : (
<div className="text-center text-muted py-3">
<i className="fas fa-calendar-alt fa-3x mb-2"></i>
<p className="mb-1">No hay eventos próximos</p>
<small>Los próximos eventos aparecerán aquí</small>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
{user && (
<div className="row">
<div className="col-12 mb-4">
<div className="card">
<div className="card-header">
<i className="fas fa-bolt me-2"></i>
Acciones Rápidas
</div>
<div className="card-body">
<div className="row">
<div className="col-md-3">
<div className="d-grid gap-2">
<button className="btn btn-primary" id="btn-new-task" disabled>
<i className="fas fa-plus me-2"></i>Nueva Tarea
</button>
</div>
</div>
<div className="col-md-3">
<div className="d-grid gap-2">
<button className="btn btn-success" id="btn-new-resource" disabled>
<i className="fas fa-box me-2"></i>Nuevo Recurso
</button>
</div>
</div>
<div className="col-md-3">
<div className="d-grid gap-2">
<a href="/calendario" className="btn btn-info">
<i className="fas fa-calendar-plus me-2"></i>Nuevo Evento
</a>
</div>
</div>
<div className="col-md-3">
<div className="d-grid gap-2">
<a href="/reportes" className="btn btn-warning">
<i className="fas fa-chart-bar me-2"></i>Ver Reportes
</a>
</div>
</div>
</div>
<hr />
<div className="text-center">
<small className="text-muted">
<i className="fas fa-info-circle me-1"></i>
Las acciones estarán disponibles en etapas posteriores
</small>
</div>
</div>
</div>
</div>
</div>
)}
{/* Access System */}
{!user && (
<div className="row">
<div className="col-12 mb-4">
<div className="card">
<div className="card-header">
<i className="fas fa-sign-in-alt me-2"></i>
Acceso al Sistema
</div>
<div className="card-body text-center">
<div className="row">
<div className="col-md-6">
<div className="d-grid gap-2">
<a href="/login" className="btn btn-primary btn-lg">
<i className="fas fa-sign-in-alt me-2"></i>Iniciar Sesión
</a>
<small className="text-muted">Accede a todas las funcionalidades del sistema</small>
</div>
</div>
<div className="col-md-6">
<div className="d-grid gap-2">
<a href="/test" className="btn btn-outline-secondary btn-lg">
<i className="fas fa-flask me-2"></i>Verificar Sistema
</a>
<small className="text-muted">Página de diagnóstico y pruebas</small>
</div>
</div>
</div>
<hr />
<div className="text-center">
<small className="text-muted">
<i className="fas fa-info-circle me-1"></i>
Para acceder a funciones avanzadas, inicia sesión con tus credenciales
</small>
</div>
</div>
</div>
</div>
</div>
)}
{/* System Information */}
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-header">
<i className="fas fa-server me-2"></i>
Información del Sistema
</div>
<div className="card-body">
<div className="row">
<div className="col-md-3">
<div className="text-center">
<i className="fas fa-code fa-2x text-primary mb-2"></i>
<div className="h6">React + TypeScript</div>
<small className="text-muted">Framework Frontend</small>
</div>
</div>
<div className="col-md-3">
<div className="text-center">
<i className="fas fa-database fa-2x text-success mb-2"></i>
<div className="h6">Node.js + Express</div>
<small className="text-muted">Backend API</small>
</div>
</div>
<div className="col-md-3">
<div className="text-center">
<i className="fas fa-calendar-alt fa-2x text-warning mb-2"></i>
<div className="h6">Calendario</div>
<small className="text-muted">Gestión de eventos</small>
</div>
</div>
<div className="col-md-3">
<div className="text-center">
<a href="/estado-proyecto" className="text-decoration-none">
<i className="fas fa-info-circle fa-2x text-info mb-2"></i>
<div className="h6 text-info">Estado del Proyecto</div>
<small className="text-muted">Ver progreso</small>
</a>
</div>
</div>
</div>
<hr />
<div className="row">
<div className="col-12">
<div className="text-center">
<div className="mb-2">
<span className="badge bg-primary fs-6 px-3 py-2">
<i className="fas fa-tag me-1"></i>
Versión 1.2.0
</span>
</div>
<small className="text-muted">
<i className="fas fa-copyright me-1"></i>
Aplicación creada y desarrollada por Lic. Ricardo MONLA,
del Departamento de Servidores de la Dirección de TIC de la
Universidad Tecnológica Nacional Facultad Regional La Rioja
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Dashboard
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
const Estadoproyecto = () => {
return (
<div className="container mt-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center mb-3">
<i className="fas fa-project-diagram fa-2x text-primary me-3"></i>
<div>
<h1 className="h3 mb-0">Estado del Proyecto</h1>
<p className="text-muted mb-0">Monitorea el estado general de los proyectos</p>
</div>
</div>
</div>
</div>
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-body text-center py-5">
<h3>Módulo de Estado del Proyecto</h3>
<p className="text-muted">Funcionalidad en desarrollo</p>
</div>
</div>
</div>
</div>
</div>
)
}
export default Estadoproyecto
@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import toast from 'react-hot-toast'
import { useAuthStore } from '../stores/authStore'
const schema = yup.object({
email: yup.string().email('Email inválido').required('Email es requerido'),
password: yup.string().required('Contraseña es requerida'),
})
type LoginForm = {
email: string
password: string
}
const Login = () => {
const navigate = useNavigate()
const location = useLocation()
const { login, isAuthenticated, isLoading } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: yupResolver(schema),
})
useEffect(() => {
if (isAuthenticated) {
const from = location.state?.from?.pathname || '/dashboard'
navigate(from, { replace: true })
}
}, [isAuthenticated, navigate, location])
const onSubmit = async (data: LoginForm) => {
try {
await login(data.email, data.password)
toast.success('Inicio de sesión exitoso')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Error en el inicio de sesión')
}
}
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-gradient">
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6 col-lg-4">
<div className="card shadow-lg border-0 rounded-lg">
<div className="card-header bg-primary text-white text-center py-4">
<h3 className="mb-0">
<i className="fas fa-cogs me-2"></i>
DTIC Bitácoras
</h3>
<p className="mb-0 mt-2">Sistema de gestión de técnicos</p>
</div>
<div className="card-body p-4">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
<i className="fas fa-envelope me-2"></i>
Email
</label>
<input
type="email"
className={`form-control ${errors.email ? 'is-invalid' : ''}`}
id="email"
{...register('email')}
placeholder="tu.email@dtic.gob.ar"
/>
{errors.email && (
<div className="invalid-feedback">{errors.email.message}</div>
)}
</div>
<div className="mb-4">
<label htmlFor="password" className="form-label">
<i className="fas fa-lock me-2"></i>
Contraseña
</label>
<div className="input-group">
<input
type={showPassword ? 'text' : 'password'}
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
id="password"
{...register('password')}
placeholder="Ingresa tu contraseña"
/>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setShowPassword(!showPassword)}
>
<i className={`fas ${showPassword ? 'fa-eye-slash' : 'fa-eye'}`}></i>
</button>
{errors.password && (
<div className="invalid-feedback">{errors.password.message}</div>
)}
</div>
</div>
<button
type="submit"
className="btn btn-primary w-100 py-2"
disabled={isLoading}
>
{isLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Iniciando sesión...
</>
) : (
<>
<i className="fas fa-sign-in-alt me-2"></i>
Iniciar Sesión
</>
)}
</button>
</form>
</div>
<div className="card-footer text-center py-3">
<small className="text-muted">
Sistema de gestión de recursos y tareas - DTIC
</small>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Login
@@ -0,0 +1,658 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
interface ReportData {
tasks: {
total: number
completed: number
pending: number
inProgress: number
cancelled: number
completionRate: number
averageTime: number
}
resources: {
total: number
available: number
assigned: number
maintenance: number
retired: number
utilizationRate: number
}
users: {
total: number
withResources: number
withoutResources: number
totalAssignedResources: number
}
technicians: {
total: number
active: number
inactive: number
admins: number
}
}
interface ChartData {
labels: string[]
datasets: Array<{
label: string
data: number[]
backgroundColor: string[]
borderColor?: string
borderWidth?: number
}>
}
const Reportes = () => {
const { user } = useAuthStore()
const [reportData, setReportData] = useState<ReportData>({
tasks: { total: 0, completed: 0, pending: 0, inProgress: 0, cancelled: 0, completionRate: 0, averageTime: 0 },
resources: { total: 0, available: 0, assigned: 0, maintenance: 0, retired: 0, utilizationRate: 0 },
users: { total: 0, withResources: 0, withoutResources: 0, totalAssignedResources: 0 },
technicians: { total: 0, active: 0, inactive: 0, admins: 0 }
})
const [tasksChart, setTasksChart] = useState<ChartData | null>(null)
const [productivityChart, setProductivityChart] = useState<ChartData | null>(null)
const [resourcesChart, setResourcesChart] = useState<ChartData | null>(null)
const [reportType, setReportType] = useState('tasks')
const [reportPeriod, setReportPeriod] = useState('month')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
useEffect(() => {
loadReportData()
setDefaultDates()
}, [])
useEffect(() => {
if (!loading) {
initializeCharts()
}
}, [loading, reportData])
const loadReportData = async () => {
try {
setLoading(true)
// Load data from all entities
const [tecnicosRes, tareasRes, recursosRes, usuariosRes] = await Promise.all([
fetch('/api/tecnicos'),
fetch('/api/tareas'),
fetch('/api/recursos'),
fetch('/api/usuarios_relacionados')
])
const tecnicos = tecnicosRes.ok ? await tecnicosRes.json() : []
const tareas = tareasRes.ok ? await tareasRes.json() : []
const recursos = recursosRes.ok ? await recursosRes.json() : []
const usuarios = usuariosRes.ok ? await usuariosRes.json() : []
// Calculate report data
const tareasArray = Array.isArray(tareas) ? tareas : []
const recursosArray = Array.isArray(recursos) ? recursos : []
const usuariosArray = Array.isArray(usuarios) ? usuarios : []
const tecnicosArray = Array.isArray(tecnicos) ? tecnicos : []
const tasksData = {
total: tareasArray.length,
completed: tareasArray.filter((t: any) => t.status === 'completed').length,
pending: tareasArray.filter((t: any) => t.status === 'pending').length,
inProgress: tareasArray.filter((t: any) => t.status === 'in_progress').length,
cancelled: tareasArray.filter((t: any) => t.status === 'cancelled').length,
completionRate: tareasArray.length > 0 ? Math.round((tareasArray.filter((t: any) => t.status === 'completed').length / tareasArray.length) * 100) : 0,
averageTime: 3.2 // Mock data
}
const resourcesData = {
total: recursosArray.length,
available: recursosArray.filter((r: any) => r.status === 'available').length,
assigned: recursosArray.filter((r: any) => r.status === 'assigned').length,
maintenance: recursosArray.filter((r: any) => r.status === 'maintenance').length,
retired: recursosArray.filter((r: any) => r.status === 'retired').length,
utilizationRate: recursosArray.length > 0 ? Math.round((recursosArray.filter((r: any) => r.status === 'assigned').length / recursosArray.length) * 100) : 0
}
const usersData = {
total: usuariosArray.length,
withResources: usuariosArray.filter((u: any) => (u.assigned_resources_count || 0) > 0).length,
withoutResources: usuariosArray.filter((u: any) => (u.assigned_resources_count || 0) === 0).length,
totalAssignedResources: usuariosArray.reduce((total: number, u: any) => total + (u.assigned_resources_count || 0), 0)
}
const techniciansData = {
total: tecnicosArray.length,
active: tecnicosArray.filter((t: any) => t.is_active).length,
inactive: tecnicosArray.filter((t: any) => !t.is_active).length,
admins: tecnicosArray.filter((t: any) => t.role === 'admin').length
}
setReportData({
tasks: tasksData,
resources: resourcesData,
users: usersData,
technicians: techniciansData
})
} catch (error) {
console.error('Error loading report data:', error)
} finally {
setLoading(false)
}
}
const initializeCharts = () => {
// Load Chart.js dynamically
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/chart.js'
script.onload = () => {
setTimeout(() => {
// Destroy existing charts to prevent "Canvas is already in use" error
const tasksCtx = document.getElementById('tasksChart') as HTMLCanvasElement
const resourcesCtx = document.getElementById('resourcesChart') as HTMLCanvasElement
// Destroy existing chart instances if they exist
if ((window as any).tasksChartInstance) {
(window as any).tasksChartInstance.destroy()
}
if ((window as any).resourcesChartInstance) {
(window as any).resourcesChartInstance.destroy()
}
// Tasks Status Chart
if (tasksCtx) {
(window as any).tasksChartInstance = new (window as any).Chart(tasksCtx, {
type: 'doughnut',
data: {
labels: ['Pendientes', 'En Progreso', 'Completadas', 'Canceladas'],
datasets: [{
data: [reportData.tasks.pending, reportData.tasks.inProgress, reportData.tasks.completed, reportData.tasks.cancelled],
backgroundColor: [
'#ffc107', // warning
'#17a2b8', // info
'#28a745', // success
'#dc3545' // danger
],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
})
}
// Resources Chart
if (resourcesCtx) {
(window as any).resourcesChartInstance = new (window as any).Chart(resourcesCtx, {
type: 'bar',
data: {
labels: ['Hardware', 'Software', 'Redes', 'Herramientas'],
datasets: [{
label: 'Total',
data: [15, 12, 8, 13],
backgroundColor: '#28a745',
borderWidth: 1
}, {
label: 'Relacionados',
data: [8, 5, 4, 3],
backgroundColor: '#17a2b8',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
})
}
}, 1000) // Wait for script to load
}
script.onerror = () => {
console.error('Failed to load Chart.js script')
}
document.head.appendChild(script)
}
const setDefaultDates = () => {
const today = new Date()
const monthAgo = new Date()
monthAgo.setMonth(today.getMonth() - 1)
setDateTo(today.toISOString().split('T')[0])
setDateFrom(monthAgo.toISOString().split('T')[0])
}
const generateReport = async () => {
setGenerating(true)
// Simulate report generation
setTimeout(() => {
setGenerating(false)
showNotification('✅ Reporte generado exitosamente', 'success')
}, 2000)
}
const resetFilters = () => {
setReportType('tasks')
setReportPeriod('month')
setDefaultDates()
showNotification('🔄 Filtros restablecidos', 'info')
}
const exportReport = (format: string) => {
showNotification(`📄 Función de exportación ${format.toUpperCase()} disponible en Etapa 3`, 'warning')
}
const printReport = () => {
showNotification('🖨️ Función de impresión disponible en Etapa 3', 'warning')
}
const showNotification = (message: string, type: string = 'info') => {
// Create notification element
const notification = document.createElement('div')
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`
notification.style.cssText = `
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
`
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.remove()
}
}, 5000)
}
useEffect(() => {
if (!loading) {
initializeCharts()
}
}, [loading, reportData])
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
<p className="mt-3">Cargando reportes...</p>
</div>
</div>
)
}
return (
<div className="container mt-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center mb-3">
<i className="fas fa-chart-bar fa-2x text-primary me-3"></i>
<div>
<h1 className="h3 mb-0">Reportes y Estadísticas</h1>
<p className="text-muted mb-0">Análisis y métricas del sistema</p>
</div>
</div>
</div>
</div>
{/* Report Filters */}
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-body">
<div className="row g-3">
<div className="col-md-3">
<label className="form-label">Tipo de Reporte</label>
<select
className="form-select"
value={reportType}
onChange={(e) => setReportType(e.target.value)}
>
<option value="tasks">Tareas</option>
<option value="resources">Recursos</option>
<option value="users">Usuarios</option>
<option value="system">Sistema</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Período</label>
<select
className="form-select"
value={reportPeriod}
onChange={(e) => setReportPeriod(e.target.value)}
>
<option value="today">Hoy</option>
<option value="week">Esta semana</option>
<option value="month">Este mes</option>
<option value="quarter">Este trimestre</option>
<option value="year">Este año</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Fecha Desde</label>
<input
type="date"
className="form-control"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="col-md-3">
<label className="form-label">Fecha Hasta</label>
<input
type="date"
className="form-control"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
<div className="row mt-3">
<div className="col-12">
<button
className="btn btn-primary me-2"
onClick={generateReport}
disabled={generating}
>
<i className="fas fa-chart-line me-2"></i>
{generating ? 'Generando...' : 'Generar Reporte'}
</button>
<button className="btn btn-outline-secondary" onClick={resetFilters}>
<i className="fas fa-times me-1"></i>Limpiar Filtros
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Charts Section */}
<div className="row mb-4">
<div className="col-md-6 mb-4">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-chart-pie me-2"></i>
Estado de Tareas
</div>
<div className="card-body">
<div className="chart-container">
<canvas id="tasksChart" width="400" height="300"></canvas>
</div>
<div className="row text-center mt-3">
<div className="col-3">
<div className="text-warning">
<div className="h5 mb-0" id="chartPending">{reportData.tasks.pending}</div>
<small>Pendientes</small>
</div>
</div>
<div className="col-3">
<div className="text-info">
<div className="h5 mb-0" id="chartInProgress">{reportData.tasks.inProgress}</div>
<small>En Progreso</small>
</div>
</div>
<div className="col-3">
<div className="text-success">
<div className="h5 mb-0" id="chartCompleted">{reportData.tasks.completed}</div>
<small>Completadas</small>
</div>
</div>
<div className="col-3">
<div className="text-danger">
<div className="h5 mb-0" id="chartCancelled">{reportData.tasks.cancelled}</div>
<small>Canceladas</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6 mb-4">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-chart-bar me-2"></i>
Recursos por Categoría
</div>
<div className="card-body">
<div className="chart-container">
<canvas id="resourcesChart" width="400" height="300"></canvas>
</div>
<div className="text-center mt-3">
<small className="text-muted">Distribución de recursos por categoría</small>
</div>
</div>
</div>
</div>
</div>
{/* Detailed Reports */}
<div className="row">
{/* Tasks Report */}
<div className="col-md-6 mb-4">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-tasks me-2"></i>
Reporte de Tareas
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-sm">
<thead>
<tr>
<th>Métrica</th>
<th>Valor</th>
<th>Cambio</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total de Tareas</td>
<td><strong>{reportData.tasks.total}</strong></td>
<td><span className="badge bg-success">+12%</span></td>
</tr>
<tr>
<td>Tareas Completadas</td>
<td><strong>{reportData.tasks.completed}</strong></td>
<td><span className="badge bg-success">+8%</span></td>
</tr>
<tr>
<td>Tiempo Promedio</td>
<td><strong>{reportData.tasks.averageTime} días</strong></td>
<td><span className="badge bg-warning">-5%</span></td>
</tr>
<tr>
<td>Tasa de Éxito</td>
<td><strong>{reportData.tasks.completionRate}%</strong></td>
<td><span className="badge bg-success">+3%</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Resources Report */}
<div className="col-md-6 mb-4">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-boxes me-2"></i>
Reporte de Recursos
</div>
<div className="card-body">
<div className="table-responsive">
<table className="table table-sm">
<thead>
<tr>
<th>Categoría</th>
<th>Total</th>
<th>Relacionados</th>
<th>Disponibles</th>
</tr>
</thead>
<tbody>
<tr>
<td>Hardware</td>
<td><strong>15</strong></td>
<td><strong>8</strong></td>
<td><strong>7</strong></td>
</tr>
<tr>
<td>Software</td>
<td><strong>12</strong></td>
<td><strong>5</strong></td>
<td><strong>7</strong></td>
</tr>
<tr>
<td>Redes</td>
<td><strong>8</strong></td>
<td><strong>4</strong></td>
<td><strong>4</strong></td>
</tr>
<tr>
<td>Herramientas</td>
<td><strong>13</strong></td>
<td><strong>3</strong></td>
<td><strong>10</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/* Recent Activity Report */}
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-header">
<i className="fas fa-history me-2"></i>
Actividad Reciente del Sistema
</div>
<div className="card-body">
<div className="timeline">
<div className="timeline-item">
<div className="timeline-marker bg-success"></div>
<div className="timeline-content">
<h6 className="text-success mb-1">Nueva tarea completada</h6>
<p className="mb-1">"Implementar API de autenticación" finalizada por María Rodríguez</p>
<small className="text-muted">Hace 2 horas</small>
</div>
</div>
<div className="timeline-item">
<div className="timeline-marker bg-info"></div>
<div className="timeline-content">
<h6 className="text-info mb-1">Recurso relacionado</h6>
<p className="mb-1">Laptop Dell Latitude 5420 relacionada con Juan García</p>
<small className="text-muted">Hace 4 horas</small>
</div>
</div>
<div className="timeline-item">
<div className="timeline-marker bg-warning"></div>
<div className="timeline-content">
<h6 className="text-warning mb-1">Nuevo usuario registrado</h6>
<p className="mb-1">Ana Martínez se unió al sistema como Visualizador</p>
<small className="text-muted">Hace 1 día</small>
</div>
</div>
<div className="timeline-item">
<div className="timeline-marker bg-danger"></div>
<div className="timeline-content">
<h6 className="text-danger mb-1">Tarea cancelada</h6>
<p className="mb-1">"Migrar base de datos legacy" fue cancelada por falta de requerimientos</p>
<small className="text-muted">Hace 2 días</small>
</div>
</div>
<div className="timeline-item">
<div className="timeline-marker bg-primary"></div>
<div className="timeline-content">
<h6 className="text-primary mb-1">Mantenimiento completado</h6>
<p className="mb-1">Router Cisco RV340 reparado y devuelto a servicio</p>
<small className="text-muted">Hace 3 días</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Export Options */}
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-header">
<i className="fas fa-file-export me-2"></i>
Opciones de Exportación
</div>
<div className="card-body">
<div className="row">
<div className="col-md-3 mb-3">
<button className="btn btn-success w-100" onClick={() => exportReport('pdf')}>
<i className="fas fa-file-pdf fa-2x mb-2"></i>
<br />
Exportar PDF
</button>
</div>
<div className="col-md-3 mb-3">
<button className="btn btn-info w-100" onClick={() => exportReport('excel')}>
<i className="fas fa-file-excel fa-2x mb-2"></i>
<br />
Exportar Excel
</button>
</div>
<div className="col-md-3 mb-3">
<button className="btn btn-warning w-100" onClick={() => exportReport('csv')}>
<i className="fas fa-file-csv fa-2x mb-2"></i>
<br />
Exportar CSV
</button>
</div>
<div className="col-md-3 mb-3">
<button className="btn btn-secondary w-100" onClick={() => exportReport('json')}>
<i className="fas fa-file-code fa-2x mb-2"></i>
<br />
Exportar JSON
</button>
</div>
</div>
<div className="alert alert-info">
<i className="fas fa-info-circle me-2"></i>
<strong>Nota:</strong> Las funciones de exportación estarán disponibles cuando se implemente el backend en la Etapa 3.
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Reportes
@@ -0,0 +1,722 @@
import { useEffect, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
import toast from 'react-hot-toast'
// CSS para estética mejorada de tablas (consistente con EntityPage.tsx)
const styles = `
/* Estética mejorada para tablas */
.entity-table {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}
.entity-table thead th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
border: none;
padding: 1rem 0.75rem;
position: sticky;
top: 0;
z-index: 10;
}
.entity-table tbody tr {
transition: all 0.2s ease;
border-bottom: 1px solid #f1f3f4;
}
.entity-table tbody tr:hover {
background-color: #f8f9ff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.entity-table tbody tr:nth-child(even) {
background-color: #fafbfc;
}
.entity-table tbody tr:nth-child(even):hover {
background-color: #f0f2ff;
}
.entity-table tbody td {
padding: 0.875rem 0.75rem;
vertical-align: middle;
border: none;
}
/* Estilos para badges en celdas */
.entity-table .badge {
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: 20px;
font-weight: 500;
}
/* Responsive improvements */
@media (max-width: 768px) {
.entity-table {
font-size: 0.875rem;
}
.entity-table thead th,
.entity-table tbody td {
padding: 0.5rem 0.375rem;
}
}
/* Dark theme overrides for entity tables */
.dark-theme .entity-table {
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.dark-theme .entity-table thead th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.dark-theme .entity-table tbody tr {
border-bottom-color: #555555;
}
.dark-theme .entity-table tbody tr:hover {
background-color: #4a4a4a;
}
.dark-theme .entity-table tbody tr:nth-child(even) {
background-color: #2d2d2d;
}
.dark-theme .entity-table tbody tr:nth-child(even):hover {
background-color: #404040;
}
.dark-theme .entity-table tbody td {
color: #ffffff;
}
`
// Inyectar estilos
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style')
styleSheet.textContent = styles
document.head.appendChild(styleSheet)
}
interface BackupFile {
filename: string
size: number
created_at: string
modified_at: string
}
const Sistema = () => {
const { user, token } = useAuthStore()
const [backups, setBackups] = useState<BackupFile[]>([])
const [loading, setLoading] = useState(true)
const [backupLoading, setBackupLoading] = useState(false)
const [restoreLoading, setRestoreLoading] = useState(false)
const [deleteLoading, setDeleteLoading] = useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = useState<string | null>(null)
const [selectedBackup, setSelectedBackup] = useState<string>('')
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [backupToDelete, setBackupToDelete] = useState<string>('')
// Check if user is admin
if (!user || user.role !== 'admin') {
return (
<div className="container mt-4">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card border-danger">
<div className="card-body text-center">
<i className="fas fa-shield-alt fa-3x text-danger mb-3"></i>
<h4 className="card-title text-danger">Acceso Denegado</h4>
<p className="card-text">
Solo los administradores pueden acceder a esta sección del sistema.
</p>
<p className="text-muted">
Si crees que deberías tener acceso, contacta al administrador del sistema.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
useEffect(() => {
loadBackups()
}, [])
const loadBackups = async () => {
try {
setLoading(true)
console.log('Sistema loadBackups: user:', user, 'token:', !!token)
const response = await fetch('/api/system/backups', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
console.log('Backups response status:', response.status)
console.log('Backups response ok:', response.ok)
if (!response.ok) {
const errorText = await response.text()
console.error('Backups response error text:', errorText)
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
console.log('Backups response data:', data)
if (data.success) {
setBackups(data.data.backups)
} else {
throw new Error(data.message || 'Error al cargar los backups')
}
} catch (error) {
console.error('Error loading backups:', error)
toast.error('Error al cargar la lista de backups')
} finally {
setLoading(false)
}
}
const handleCreateBackup = async () => {
try {
setBackupLoading(true)
console.log('handleCreateBackup: Starting backup creation')
// Create AbortController for timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 60000) // 60 second timeout for backup operations
const response = await fetch('/api/system/backup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
signal: controller.signal,
})
clearTimeout(timeoutId)
console.log('handleCreateBackup: Response status:', response.status)
console.log('handleCreateBackup: Response ok:', response.ok)
if (!response.ok) {
const errorText = await response.text()
console.error('handleCreateBackup: Response error text:', errorText)
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
console.log('handleCreateBackup: Response data:', data)
if (data.success) {
toast.success('Backup creado exitosamente')
// Reload backups list
await loadBackups()
} else {
throw new Error(data.message || 'Error al crear el backup')
}
} catch (error) {
console.error('Error creating backup:', error)
if (error instanceof Error && error.name === 'AbortError') {
toast.error('Timeout: La creación del backup tomó demasiado tiempo')
} else {
toast.error('Error al crear el backup')
}
} finally {
setBackupLoading(false)
}
}
const handleRestoreBackup = async () => {
if (!selectedBackup) {
toast.error('Selecciona un backup para restaurar')
return
}
try {
setRestoreLoading(true)
const response = await fetch('/api/system/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ backup_file: selectedBackup }),
})
const data = await response.json()
if (data.success) {
toast.success('Base de datos restaurada exitosamente')
setShowRestoreConfirm(false)
setSelectedBackup('')
} else {
throw new Error(data.message || 'Error al restaurar el backup')
}
} catch (error) {
console.error('Error restoring backup:', error)
toast.error('Error al restaurar el backup')
} finally {
setRestoreLoading(false)
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('es-AR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const confirmRestore = () => {
if (!selectedBackup) {
toast.error('Selecciona un backup para restaurar')
return
}
setShowRestoreConfirm(true)
}
const handleDownloadBackup = async (filename: string) => {
try {
setDownloadLoading(filename)
console.log('handleDownloadBackup: Starting download for', filename)
const response = await fetch(`/api/system/backups/${filename}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('handleDownloadBackup: Response error text:', errorText)
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
// Create a blob from the response
const blob = await response.blob()
// Create a download link and trigger the download
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success(`Backup ${filename} descargado exitosamente`)
} catch (error) {
console.error('Error downloading backup:', error)
toast.error('Error al descargar el backup')
} finally {
setDownloadLoading(null)
}
}
const handleDeleteBackup = async () => {
if (!backupToDelete) {
toast.error('No se seleccionó ningún backup para eliminar')
return
}
try {
setDeleteLoading(backupToDelete)
console.log('handleDeleteBackup: Starting deletion for', backupToDelete)
const response = await fetch(`/api/system/backups/${backupToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('handleDeleteBackup: Response error text:', errorText)
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
console.log('handleDeleteBackup: Response data:', data)
if (data.success) {
toast.success('Backup eliminado exitosamente')
setShowDeleteConfirm(false)
setBackupToDelete('')
// Reload backups list
await loadBackups()
} else {
throw new Error(data.message || 'Error al eliminar el backup')
}
} catch (error) {
console.error('Error deleting backup:', error)
toast.error('Error al eliminar el backup')
} finally {
setDeleteLoading(null)
}
}
const confirmDelete = (filename: string) => {
setBackupToDelete(filename)
setShowDeleteConfirm(true)
}
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
<p className="mt-3">Cargando sistema...</p>
</div>
</div>
)
}
return (
<div className="container mt-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center justify-content-between mb-3">
<div className="d-flex align-items-center">
<i className="fas fa-server fa-2x text-primary me-3"></i>
<div>
<h1 className="h3 mb-0">Sistema - Backup y Restauración</h1>
<p className="text-muted mb-0">Gestión de backups de la base de datos</p>
</div>
</div>
<div className="text-end">
<span className="badge bg-primary fs-6">v2.1.2</span>
<p className="text-muted small mb-0 mt-1">Versión del Sistema</p>
</div>
</div>
</div>
</div>
{/* Backup Section */}
<div className="row mb-4">
<div className="col-md-6">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-save text-success me-2"></i>
Crear Backup
</div>
<div className="card-body text-center">
<i className="fas fa-database fa-3x text-success mb-3"></i>
<p className="card-text mb-4">
Crea un backup completo de la base de datos del sistema.
Los backups se almacenan automáticamente en el servidor.
</p>
<button
className="btn btn-success btn-lg"
onClick={handleCreateBackup}
disabled={backupLoading}
>
{backupLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Creando Backup... (puede tomar hasta 1 minuto)
</>
) : (
<>
<i className="fas fa-save me-2"></i>
Crear Backup
</>
)}
</button>
</div>
</div>
</div>
{/* Restore Section */}
<div className="col-md-6">
<div className="card h-100">
<div className="card-header">
<i className="fas fa-undo text-warning me-2"></i>
Restaurar Backup
</div>
<div className="card-body text-center">
<i className="fas fa-history fa-3x text-warning mb-3"></i>
<p className="card-text mb-4">
Restaura la base de datos desde un backup anterior.
Esta acción sobrescribirá todos los datos actuales.
</p>
<div className="mb-3">
<select
className="form-select"
value={selectedBackup}
onChange={(e) => setSelectedBackup(e.target.value)}
>
<option value="">Seleccionar backup...</option>
{backups.map((backup) => (
<option key={backup.filename} value={backup.filename}>
{backup.filename} - {formatDate(backup.created_at)}
</option>
))}
</select>
</div>
<button
className="btn btn-warning btn-lg"
onClick={confirmRestore}
disabled={!selectedBackup || restoreLoading}
>
{restoreLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Restaurando...
</>
) : (
<>
<i className="fas fa-undo me-2"></i>
Restaurar Backup
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Backup History */}
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<i className="fas fa-list text-info me-2"></i>
Historial de Backups
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={loadBackups}
disabled={loading}
>
<i className="fas fa-sync me-1"></i>
Actualizar
</button>
</div>
<div className="card-body">
{backups.length > 0 ? (
<div className="table-responsive">
<table className="table entity-table">
<thead>
<tr>
<th>Archivo</th>
<th>Tamaño</th>
<th>Fecha de Creación</th>
<th>Última Modificación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td>
<i className="fas fa-file-archive text-secondary me-2"></i>
{backup.filename}
</td>
<td>{formatFileSize(backup.size)}</td>
<td>{formatDate(backup.created_at)}</td>
<td>{formatDate(backup.modified_at)}</td>
<td>
<div className="btn-group btn-group-sm" role="group">
<button
className="btn btn-outline-primary btn-sm"
onClick={() => handleDownloadBackup(backup.filename)}
disabled={downloadLoading === backup.filename}
title="Descargar backup"
>
{downloadLoading === backup.filename ? (
<span className="spinner-border spinner-border-sm" role="status"></span>
) : (
<i className="fas fa-download"></i>
)}
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => confirmDelete(backup.filename)}
disabled={deleteLoading === backup.filename}
title="Eliminar backup"
>
{deleteLoading === backup.filename ? (
<span className="spinner-border spinner-border-sm" role="status"></span>
) : (
<i className="fas fa-trash"></i>
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-4">
<i className="fas fa-inbox fa-3x text-muted mb-3"></i>
<p className="text-muted">No hay backups disponibles</p>
<small className="text-muted">
Los backups aparecerán aquí una vez que se creen
</small>
</div>
)}
</div>
</div>
</div>
</div>
{/* Restore Confirmation Modal */}
{showRestoreConfirm && (
<div className="modal show d-block" tabIndex={-1}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="fas fa-exclamation-triangle text-warning me-2"></i>
Confirmar Restauración
</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowRestoreConfirm(false)}
></button>
</div>
<div className="modal-body">
<div className="alert alert-warning">
<i className="fas fa-exclamation-triangle me-2"></i>
<strong>Advertencia:</strong> Esta acción restaurará la base de datos
desde el backup seleccionado. Todos los datos actuales serán sobrescritos
y se perderán los cambios realizados después de la fecha del backup.
</div>
<p>¿Estás seguro de que deseas continuar?</p>
<div className="text-muted small">
<strong>Backup seleccionado:</strong> {selectedBackup}
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowRestoreConfirm(false)}
>
Cancelar
</button>
<button
type="button"
className="btn btn-warning"
onClick={handleRestoreBackup}
disabled={restoreLoading}
>
{restoreLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Restaurando...
</>
) : (
'Confirmar Restauración'
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="modal show d-block" tabIndex={-1}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="fas fa-exclamation-triangle text-danger me-2"></i>
Confirmar Eliminación
</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowDeleteConfirm(false)}
></button>
</div>
<div className="modal-body">
<div className="alert alert-danger">
<i className="fas fa-exclamation-triangle me-2"></i>
<strong>Advertencia:</strong> Esta acción eliminará permanentemente el archivo de backup.
Una vez eliminado, no podrá ser recuperado.
</div>
<p>¿Estás seguro de que deseas eliminar este backup?</p>
<div className="text-muted small">
<strong>Archivo a eliminar:</strong> {backupToDelete}
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowDeleteConfirm(false)}
>
Cancelar
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleDeleteBackup}
disabled={deleteLoading !== null}
>
{deleteLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Eliminando...
</>
) : (
'Confirmar Eliminación'
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal Backdrop */}
{(showRestoreConfirm || showDeleteConfirm) && (
<div className="modal-backdrop show"></div>
)}
</div>
)
}
export default Sistema